From 1b1217ace24d22465b6ceedd22a4d6f5a69860c9 Mon Sep 17 00:00:00 2001 From: virtus Date: Sun, 3 May 2026 20:57:29 +0700 Subject: [PATCH] feat(storage): add delete_href method to remove files and clean up empty directories chore(docker): remove MongoDB service and related configurations from local setup feat(migrations): create ChapterMeta table and add search_name, size_bytes, mtime_epoch, lastScannedAt, review_status, and review_payload columns to SourceAsset chore(dependencies): remove motor and pymongo from project dependencies --- CROSS_REPO_ENDPOINT_MATRIX.md | 8 + FEATURES.md | 1 + FLOWS.md | 20 + README.md | 36 +- app/config.py | 5 +- app/main.py | 3358 ++++++++++++++++- app/storage.py | 14 + docker-compose.yml | 20 +- migrations/2026_05_add_chapter_meta.sql | 11 + .../2026_05_import_search_and_sessions.sql | 39 + pyproject.toml | 1 - uv.lock | 85 +- 12 files changed, 3346 insertions(+), 252 deletions(-) create mode 100644 migrations/2026_05_add_chapter_meta.sql create mode 100644 migrations/2026_05_import_search_and_sessions.sql diff --git a/CROSS_REPO_ENDPOINT_MATRIX.md b/CROSS_REPO_ENDPOINT_MATRIX.md index bd9eb2a..79477fd 100644 --- a/CROSS_REPO_ENDPOINT_MATRIX.md +++ b/CROSS_REPO_ENDPOINT_MATRIX.md @@ -26,9 +26,17 @@ Legend: | Comment | `GET/POST /api/truyen/{id}/comments` | Y | Y | Y | | | Rating | `POST /api/truyen/{id}/rate` | Y | Y | N | Mobile chua thay rating flow | | Search | `GET /api/truyen/suggest` | Y | Y | N | Mobile search suggest can bo sung | +| Import | `GET /api/import/assets/search` | Y | Y | N | Web MOD import wizard step 1 | +| Import | `GET /api/import/assets/{id}/preview-metadata` | Y | Y | N | Web MOD import wizard step 2 | +| Import | `POST /api/import/assets/{id}/ai-suggest` | Y | Y | N | Gen toi da 6 genres + short description | +| Import | `POST /api/import/assets/{id}/review` | Y | Y | N | Save reviewed metadata before import | +| Import | `POST /api/import/assets/{id}/parse-preview` | Y | Y | N | TOC/regex-start preview (10 head/mid/tail samples) | +| Import | `POST /api/import/assets/{id}/start-import` | Y | Y | N | Start import session | +| Import | `GET /api/import/sessions/{sessionId}` | Y | Y | N | Poll import progress | ## Priority gaps de dong bo tiep 1. Mobile: `user/settings`, `recommendations`, `rate`, `suggest`. 2. Web/Mobile chapter-read strategy can unify (`chapters/{id}` vs `by-number`). 3. Chuan hoa error contract implementation theo `CONTRACT.md`. +4. Mobile import flow currently not planned (MOD-only on web). diff --git a/FEATURES.md b/FEATURES.md index 4abca39..5596772 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -18,6 +18,7 @@ Tinh nang backend cho web + mobile. | Domain | Endpoint Group | Status | Notes | |---|---|---|---| | Content management | `/api/mod/*` | partial | da co nhieu route, tiep tuc bo sung nhu cau | +| EPUB import | `/api/import/*` | done | review-first wizard APIs + progress session | ## Contract + Parity Responsibility diff --git a/FLOWS.md b/FLOWS.md index c5c0cad..1a8b647 100644 --- a/FLOWS.md +++ b/FLOWS.md @@ -36,3 +36,23 @@ Backend flow theo domain, de web/mobile follow giong nhau. - Comments: `/api/truyen/{id}/comments`. - Rating: `/api/truyen/{id}/rate`. - Rule: enforce auth + anti-invalid payload + stable error format. + +## Flow E: EPUB Import (MOD/ADMIN) + +- Step 1 search source: + - `/api/import/assets/search` +- Step 2 review metadata: + - `/api/import/assets/{id}/preview-metadata` + - `/api/import/assets/{id}/ai-suggest` + - `/api/import/assets/{id}/review` +- Step 3 chapter split preview: + - `/api/import/assets/{id}/parse-preview` + - split mode: `toc` or `regex` (chapter-start pattern only) +- Step 4 start import + progress: + - `/api/import/assets/{id}/start-import` + - `/api/import/sessions/{sessionId}` + +Rules: +- No filesystem scan in search request path (scan by cron/incremental). +- Reviewer confirms metadata before import. +- Import writes NAS content + chapter refs, then updates novel counters. diff --git a/README.md b/README.md index e12015b..957ae32 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ This project is Python-first (FastAPI), with production-focused Docker setup and - FastAPI - UV (package manager / runner) - PostgreSQL (structured data) -- MongoDB (chapter content + user recommendations) ## API Base URL @@ -27,7 +26,6 @@ Required keys: ```env DATABASE_URL=postgresql://reader:reader@localhost:5432/reader -MONGODB_URI=mongodb://localhost:27017/reader NEXTAUTH_SECRET=replace-with-strong-secret MOBILE_JWT_SECRET=replace-with-strong-secret # Comma-separated allowed Google OAuth client IDs @@ -79,15 +77,15 @@ WEB_GOOGLE_CLIENT_ID=web-client-id.apps.googleusercontent.com WEB_GOOGLE_CLIENT_SECRET=replace-with-web-google-client-secret ``` -### Full local stack (API local + Postgres + Mongo) +### Full local stack (API local + Postgres) ```bash -docker compose --profile localdb up -d --build api-local postgres mongo +docker compose --profile localdb up -d --build api-local postgres ``` Notes: - `api` listens on port `8000` and is intended for external DB deployments. -- `api-local` listens on port `8001` and automatically points to `postgres` + `mongo` containers. +- `api-local` listens on port `8001` and automatically points to `postgres` container. - `web` listens on port `3000` and calls API internally through `http://api:8000`. ### NAS mount points (chapter content + EPUB source) @@ -153,6 +151,29 @@ For your EPUB structure (folder per novel, multiple `.epub` parts inside), mount - POST /api/truyen/{id}/rate - GET /api/truyen/suggest - GET /api/chapters/{chapterId} +- GET /api/import/assets/search +- GET /api/import/assets/{assetId}/preview-metadata +- POST /api/import/assets/{assetId}/ai-suggest +- POST /api/import/assets/{assetId}/review +- POST /api/import/assets/{assetId}/parse-preview +- POST /api/import/assets/{assetId}/start-import +- GET /api/import/sessions/{sessionId} + +## Simple EPUB Import Flow (Review-first) + +MOD/ADMIN flow on new import wizard: + +1. Search source EPUB by name (DB index): `GET /api/import/assets/search` +2. Review/edit metadata: `GET /api/import/assets/{id}/preview-metadata` + `POST /api/import/assets/{id}/review` +3. Preview chapter split (TOC or regex-start): `POST /api/import/assets/{id}/parse-preview` +4. Start import and poll progress: + - `POST /api/import/assets/{id}/start-import` + - `GET /api/import/sessions/{sessionId}` + +AI assist in step 2: +- `POST /api/import/assets/{id}/ai-suggest` +- Returns up to 6 genres + 1 short description. +- New genres are allowed and created immediately on apply. ## NAS Migration Ops @@ -160,7 +181,7 @@ For your EPUB structure (folder per novel, multiple `.epub` parts inside), mount Run SQL in `migrations/2026_04_nas_content_storage.sql` against PostgreSQL. -### 2) Backfill existing chapter content from Mongo -> NAS + ChapterContentRef +### 2) Backfill existing chapter content to NAS + ChapterContentRef Dry-run first: @@ -197,8 +218,7 @@ CHAPTER_CONTENT_MODE=nas_first ``` Values: -- `nas_first` (default): read NAS ref first, fallback Mongo. -- `mongo_first`: keep Mongo-first during cautious rollout. +- `nas_first` (default): read NAS ref first. ## Notes diff --git a/app/config.py b/app/config.py index 7e63776..62a3430 100644 --- a/app/config.py +++ b/app/config.py @@ -8,7 +8,6 @@ class Settings(BaseSettings): app_env: str = "development" database_url: str - mongodb_uri: str = "" google_client_id: str = "" nextauth_secret: str = "" @@ -22,13 +21,15 @@ class Settings(BaseSettings): r2_public_base_url: str = "" nas_content_root: str = "./data/content" epub_source_root: str = "./data/epub-source" - chapter_content_mode: str = "nas_first" # nas_first | mongo_first + chapter_content_mode: str = "nas_first" auto_schema_bootstrap: str = "false" deepseek_key: str = "" deepseek_model: str = "deepseek-chat" openrouter_key: str = "" openrouter_paused: str = "true" + import_scan_interval_minutes: int = 30 + import_scan_limit: int = 2000 @property def google_client_id_list(self) -> list[str]: diff --git a/app/main.py b/app/main.py index 13a4ca5..4c7db3c 100644 --- a/app/main.py +++ b/app/main.py @@ -1,16 +1,25 @@ from __future__ import annotations +import asyncio import datetime as dt import hashlib +import json +import os import random +import re import secrets +import tempfile import uuid +from difflib import SequenceMatcher from contextlib import asynccontextmanager from pathlib import Path from typing import Any -from fastapi import Depends, FastAPI, HTTPException, Query, Request +import boto3 +from fastapi import Body, Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile from fastapi.middleware.cors import CORSMiddleware +import httpx +from fastapi.responses import Response from google.auth.transport import requests as google_requests from google.oauth2 import id_token as google_id_token from pydantic import BaseModel, Field @@ -25,9 +34,18 @@ from app.storage import storage @asynccontextmanager async def lifespan(_: FastAPI): + global _SCAN_TASK if str(settings.auto_schema_bootstrap).lower() in {"1", "true", "yes", "on"}: await _ensure_migration_tables() + if _SCAN_TASK is None or _SCAN_TASK.done(): + _SCAN_TASK = asyncio.create_task(_scan_loop(), name="import-scan-loop") yield + if _SCAN_TASK and not _SCAN_TASK.done(): + _SCAN_TASK.cancel() + try: + await _SCAN_TASK + except asyncio.CancelledError: + pass async def _ensure_migration_tables() -> None: @@ -52,6 +70,33 @@ async def _ensure_migration_tables() -> None: CREATE UNIQUE INDEX IF NOT EXISTS "SourceAsset_sha256_key" ON "SourceAsset"(sha256) ''', ''' + ALTER TABLE "SourceAsset" + ADD COLUMN IF NOT EXISTS search_name TEXT, + ADD COLUMN IF NOT EXISTS size_bytes BIGINT, + ADD COLUMN IF NOT EXISTS mtime_epoch BIGINT, + ADD COLUMN IF NOT EXISTS "lastScannedAt" TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS review_status TEXT NOT NULL DEFAULT 'discovered', + ADD COLUMN IF NOT EXISTS review_payload JSONB + ''', + ''' + CREATE INDEX IF NOT EXISTS "SourceAsset_search_name_idx" ON "SourceAsset"(search_name) + ''', + ''' + CREATE TABLE IF NOT EXISTS "ImportSession" ( + id TEXT PRIMARY KEY, + "sourceAssetId" TEXT NOT NULL REFERENCES "SourceAsset"(id) ON DELETE CASCADE, + "novelId" TEXT, + status TEXT NOT NULL DEFAULT 'pending', + phase TEXT NOT NULL DEFAULT 'prepare', + "progressPct" DOUBLE PRECISION NOT NULL DEFAULT 0, + log TEXT, + "resultJson" JSONB, + "createdBy" TEXT, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + ''', + ''' CREATE TABLE IF NOT EXISTS "ImportJob" ( id TEXT PRIMARY KEY, "sourceAssetId" TEXT NOT NULL REFERENCES "SourceAsset"(id) ON DELETE CASCADE, @@ -78,14 +123,24 @@ async def _ensure_migration_tables() -> None: 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 "ImportCandidateChapter" ( + id TEXT PRIMARY KEY, + "jobId" TEXT NOT NULL, + "candidateNumber" INT NOT NULL, + "candidateTitle" TEXT, + "candidateHash" TEXT, + "matchedChapterId" TEXT, + action TEXT NOT NULL, + reason TEXT, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + ''', + ''' CREATE TABLE IF NOT EXISTS "AssetNovelMapping" ( id TEXT PRIMARY KEY, "sourceAssetId" TEXT NOT NULL REFERENCES "SourceAsset"(id) ON DELETE CASCADE, @@ -136,6 +191,98 @@ async def _ensure_migration_tables() -> None: app = FastAPI(title=settings.app_name, lifespan=lifespan) +_SCAN_TASK: asyncio.Task[Any] | None = None +_IMPORT_TASKS: set[asyncio.Task[Any]] = set() + + +def _normalized_search_name(value: str) -> str: + raw = (value or "").replace("\\", "/") + base = raw.split("/")[-1] + stem = base.rsplit(".", 1)[0] + return _norm_title(stem) + + +async def _discover_assets_incremental(limit: int = 2000) -> dict[str, int]: + from app.database import SessionLocal + + root = Path(settings.epub_source_root) + if not root.exists(): + return {"scanned": 0, "discovered": 0, "updated": 0} + + found = sorted(root.rglob("*.epub"))[: max(1, limit)] + discovered = 0 + updated = 0 + scanned = 0 + + session = SessionLocal() + try: + for epub_path in found: + stat = epub_path.stat() + rel_path = str(epub_path.relative_to(root)) + scanned += 1 + sha256_now = _asset_file_sha256(epub_path) + existing = ( + await session.execute( + text('SELECT id, sha256, size_bytes, mtime_epoch FROM "SourceAsset" WHERE sha256 = :sha LIMIT 1'), + {"sha": sha256_now}, + ) + ).mappings().first() + changed = ( + not existing + or int(existing.get("size_bytes") or -1) != int(stat.st_size) + or int(existing.get("mtime_epoch") or -1) != int(stat.st_mtime) + ) + sha256 = sha256_now if changed else str(existing.get("sha256") or sha256_now) + now_epoch = int(stat.st_mtime) + + if existing: + await session.execute( + text( + 'UPDATE "SourceAsset" SET sha256 = :sha, size_bytes = :size, mtime_epoch = :mtime, ' + 'search_name = :search_name, "lastScannedAt" = NOW(), "updatedAt" = NOW() WHERE id = :id' + ), + { + "id": existing["id"], + "sha": sha256, + "size": int(stat.st_size), + "mtime": now_epoch, + "search_name": _normalized_search_name(rel_path), + }, + ) + updated += 1 + else: + await session.execute( + text( + 'INSERT INTO "SourceAsset" (id, path, sha256, status, search_name, size_bytes, mtime_epoch, "lastScannedAt") ' + 'VALUES (:id, :path, :sha, :status, :search_name, :size, :mtime, NOW())' + ), + { + "id": _new_id("asset_"), + "path": rel_path, + "sha": sha256, + "status": "discovered", + "search_name": _normalized_search_name(rel_path), + "size": int(stat.st_size), + "mtime": now_epoch, + }, + ) + discovered += 1 + await session.commit() + finally: + await session.close() + + return {"scanned": scanned, "discovered": discovered, "updated": updated} + + +async def _scan_loop() -> None: + interval_seconds = max(60, int(settings.import_scan_interval_minutes) * 60) + while True: + try: + await _discover_assets_incremental(limit=settings.import_scan_limit) + except Exception: + pass + await asyncio.sleep(interval_seconds) + app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origin_list, @@ -304,16 +451,19 @@ 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_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() + try: + 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() + except Exception: + return [], [] editor_docs = [dict(r) for r in editor_rows] user_docs = [dict(r) for r in user_rows] @@ -506,46 +656,6 @@ def _to_hot_slide(row: dict[str, Any], source: str) -> dict[str, Any]: } -@app.get("/api/home") -async def get_home_data(db: AsyncSession = Depends(get_db_session)): - today = dt.datetime.now(dt.timezone.utc).date() - week_start = today - dt.timedelta(days=7) - month_start = today - dt.timedelta(days=30) - - weekly_raw = await _fetch_home_ranking_rows(db, since=week_start, take=600) - monthly_raw = await _fetch_home_ranking_rows(db, since=month_start, take=600) - all_time_raw = await _fetch_home_ranking_rows(db, take=800) - popular_fallback = await _fetch_home_popular_fallback(db, take=400) - random_pool = await _fetch_home_random_pool(db, take=420) - top_recommendations, editor_recommendations = await _fetch_home_manual_recommendations(db) - recent_comments = await _fetch_home_recent_comments(db, take=10) - latest_novels = await _fetch_home_latest_novels(db, take=5) - - weekly_ranking = _fill_unique_rows(_collapse_series_rows(weekly_raw), _collapse_series_rows(popular_fallback), 5) - monthly_ranking = _fill_unique_rows(_collapse_series_rows(monthly_raw), _collapse_series_rows(popular_fallback), 5) - all_time_ranking = _fill_unique_rows(_collapse_series_rows(all_time_raw), _collapse_series_rows(popular_fallback), 5) - - hot_primary = [_to_hot_slide(item, "week") for item in weekly_ranking[:5]] + [_to_hot_slide(item, "month") for item in monthly_ranking[:5]] - hot_fallback = [_to_hot_slide(item, "all") for item in all_time_ranking[:8]] - hot_slides = _fill_unique_rows(hot_primary, hot_fallback, 10) - - used_hot_ids = {item["id"] for item in hot_slides} - random_candidates = [item for item in _collapse_series_rows(_shuffle_rows(random_pool)) if item["id"] not in used_hot_ids] - random_novels = _fill_unique_rows(random_candidates, _shuffle_rows(random_pool), 12) - - return { - "hotSlides": hot_slides, - "randomNovels": random_novels, - "recommendedByCountItems": top_recommendations, - "editorRecommendedItems": editor_recommendations, - "weeklyRanking": weekly_ranking, - "monthlyRanking": monthly_ranking, - "allTimeRanking": all_time_ranking, - "latestNovels": latest_novels, - "recentComments": recent_comments, - } - - async def _load_bookmark_with_novel(db: AsyncSession, user_id: str, novel_id: str) -> dict[str, Any] | None: result = await db.execute( text( @@ -737,6 +847,1534 @@ async def get_genre_by_slug(slug: str, db: AsyncSession = Depends(get_db_session return dict(row) +@app.get("/api/mod/the-loai") +async def mod_list_genres( + 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") + rows = ( + await db.execute( + text('SELECT id, name, slug, description, icon FROM "Genre" ORDER BY name ASC') + ) + ).mappings().all() + return [dict(r) for r in rows] + + +@app.post("/api/mod/the-loai") +async def mod_create_genre( + payload: dict[str, Any] = Body(...), + db: AsyncSession = Depends(get_db_session), + user: dict = Depends(require_current_user), +): + if user.get("role") not in ("MOD", "ADMIN"): + raise HTTPException(status_code=403, detail="Forbidden") + name = " ".join((str(payload.get("name") or "")).split()).strip() + if not name: + raise HTTPException(status_code=400, detail="Tên thể loại không hợp lệ") + slug = _norm_title(name).replace(" ", "-")[:120] or _new_id("genre_") + + existing = ( + await db.execute( + text('SELECT id, name, slug, description, icon FROM "Genre" WHERE lower(name) = :name OR slug = :slug LIMIT 1'), + {"name": name.lower(), "slug": slug}, + ) + ).mappings().first() + if existing: + return dict(existing) + + row = ( + await db.execute( + text( + 'INSERT INTO "Genre" (id, name, slug, description, icon) ' + 'VALUES (:id, :name, :slug, :description, :icon) ' + 'RETURNING id, name, slug, description, icon' + ), + { + "id": _new_id("genre_"), + "name": name, + "slug": slug, + "description": payload.get("description"), + "icon": payload.get("icon"), + }, + ) + ).mappings().first() + await db.commit() + return dict(row) if row else {} + + +@app.put("/api/mod/the-loai") +async def mod_update_genre( + payload: dict[str, Any] = Body(...), + db: AsyncSession = Depends(get_db_session), + user: dict = Depends(require_current_user), +): + if user.get("role") not in ("MOD", "ADMIN"): + raise HTTPException(status_code=403, detail="Forbidden") + genre_id = str(payload.get("id") or "").strip() + if not genre_id: + raise HTTPException(status_code=400, detail="id là bắt buộc") + name = " ".join((str(payload.get("name") or "")).split()).strip() + if not name: + raise HTTPException(status_code=400, detail="Tên thể loại không hợp lệ") + slug = _norm_title(name).replace(" ", "-")[:120] or genre_id + + row = ( + await db.execute( + text( + 'UPDATE "Genre" SET name = :name, slug = :slug, description = :description, icon = :icon ' + 'WHERE id = :id RETURNING id, name, slug, description, icon' + ), + { + "id": genre_id, + "name": name, + "slug": slug, + "description": payload.get("description"), + "icon": payload.get("icon"), + }, + ) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Genre not found") + await db.commit() + return dict(row) + + +@app.delete("/api/mod/the-loai") +async def mod_delete_genre( + 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") + + await db.execute(text('DELETE FROM "NovelGenre" WHERE "genreId" = :id'), {"id": id}) + row = ( + await db.execute( + text('DELETE FROM "Genre" WHERE id = :id RETURNING id, name'), + {"id": id}, + ) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Genre not found") + await db.commit() + return {"id": row["id"], "name": row["name"], "deleted": True} + + +@app.post("/api/mod/the-loai/merge") +async def mod_merge_genre( + payload: dict[str, Any] = Body(...), + db: AsyncSession = Depends(get_db_session), + user: dict = Depends(require_current_user), +): + if user.get("role") not in ("MOD", "ADMIN"): + raise HTTPException(status_code=403, detail="Forbidden") + source_id = str(payload.get("sourceId") or "").strip() + target_id = str(payload.get("targetId") or "").strip() + if not source_id or not target_id: + raise HTTPException(status_code=400, detail="sourceId và targetId là bắt buộc") + if source_id == target_id: + raise HTTPException(status_code=400, detail="sourceId và targetId phải khác nhau") + + src = (await db.execute(text('SELECT id FROM "Genre" WHERE id = :id LIMIT 1'), {"id": source_id})).mappings().first() + tgt = (await db.execute(text('SELECT id FROM "Genre" WHERE id = :id LIMIT 1'), {"id": target_id})).mappings().first() + if not src or not tgt: + raise HTTPException(status_code=404, detail="Genre not found") + + await db.execute( + text( + 'INSERT INTO "NovelGenre" ("novelId", "genreId") ' + 'SELECT "novelId", :target_id FROM "NovelGenre" WHERE "genreId" = :source_id ' + 'ON CONFLICT ("novelId", "genreId") DO NOTHING' + ), + {"source_id": source_id, "target_id": target_id}, + ) + await db.execute(text('DELETE FROM "NovelGenre" WHERE "genreId" = :source_id'), {"source_id": source_id}) + await db.execute(text('DELETE FROM "Genre" WHERE id = :source_id'), {"source_id": source_id}) + await db.commit() + return {"merged": True, "sourceId": source_id, "targetId": target_id} + + +async def _ensure_unique_slug(db: AsyncSession, *, table: str, slug: str, current_id: str | None = None) -> str: + base = slug or _new_id("slug_") + candidate = base + idx = 1 + while True: + row = (await db.execute(text(f'SELECT id FROM "{table}" WHERE slug = :slug LIMIT 1'), {"slug": candidate})).mappings().first() + if not row: + return candidate + if current_id and str(row.get("id") or "") == current_id: + return candidate + idx += 1 + candidate = f"{base}-{idx}" + + +async def _resolve_series_id( + db: AsyncSession, + *, + series_id: str | None, + series_name: str | None, +) -> str | None: + sid = str(series_id or "").strip() + if sid: + exists = (await db.execute(text('SELECT id FROM "Series" WHERE id = :id LIMIT 1'), {"id": sid})).mappings().first() + if exists: + return sid + raise HTTPException(status_code=400, detail="Series not found") + + name = " ".join((series_name or "").split()).strip() + if not name: + return None + slug = _norm_title(name).replace(" ", "-")[:120] or _new_id("series_") + existing = ( + await db.execute( + text('SELECT id FROM "Series" WHERE lower(name) = :name OR slug = :slug LIMIT 1'), + {"name": name.lower(), "slug": slug}, + ) + ).mappings().first() + if existing: + return str(existing["id"]) + sid = _new_id("series_") + slug = await _ensure_unique_slug(db, table="Series", slug=slug) + await db.execute( + text('INSERT INTO "Series" (id, name, slug, description, "createdAt", "updatedAt") VALUES (:id, :name, :slug, :description, NOW(), NOW())'), + {"id": sid, "name": name, "slug": slug, "description": None}, + ) + return sid + + +async def _set_novel_genres(db: AsyncSession, novel_id: str, genre_ids: list[str]) -> None: + clean_ids = [str(g).strip() for g in (genre_ids or []) if str(g).strip()] + await db.execute(text('DELETE FROM "NovelGenre" WHERE "novelId" = :novel_id'), {"novel_id": novel_id}) + if not clean_ids: + return + valid_rows = ( + await db.execute( + text('SELECT id FROM "Genre" WHERE id = ANY(:ids)'), + {"ids": clean_ids}, + ) + ).mappings().all() + for r in valid_rows: + await db.execute( + text('INSERT INTO "NovelGenre" ("novelId", "genreId") VALUES (:novel_id, :genre_id) ON CONFLICT DO NOTHING'), + {"novel_id": novel_id, "genre_id": str(r["id"])}, + ) + + +async def _delete_novel_by_id(db: AsyncSession, novel_id: str) -> bool: + novel_row = ( + await db.execute( + text('SELECT id, "coverUrl" FROM "Novel" WHERE id = :id LIMIT 1'), + {"id": novel_id}, + ) + ).mappings().first() + if not novel_row: + return False + + chapter_rows = ( + await db.execute( + text( + 'SELECT m.id, m.number, c."txtHref", c."rawHtmlHref" ' + 'FROM "ChapterMeta" m ' + 'LEFT JOIN "ChapterContentRef" c ON c."chapterId" = m.id ' + 'WHERE m."novelId" = :novel_id' + ), + {"novel_id": novel_id}, + ) + ).mappings().all() + + for row in chapter_rows: + txt_href = str(row.get("txtHref") or "").strip() + raw_href = str(row.get("rawHtmlHref") or "").strip() + if txt_href: + try: + storage.delete_href(txt_href) + except Exception: + pass + if raw_href: + try: + storage.delete_href(raw_href) + except Exception: + pass + + chapter_ids = [str(r["id"]) for r in chapter_rows] + if chapter_ids: + await db.execute(text('DELETE FROM "ChapterContentRef" WHERE "chapterId" = ANY(:chapter_ids)'), {"chapter_ids": chapter_ids}) + + cover_key = _r2_key_from_cover_url(str(novel_row.get("coverUrl") or "")) + if cover_key: + _delete_r2_key(cover_key) + + await db.execute(text('DELETE FROM "ChapterMeta" WHERE "novelId" = :novel_id'), {"novel_id": novel_id}) + await db.execute(text('DELETE FROM "NovelGenre" WHERE "novelId" = :novel_id'), {"novel_id": novel_id}) + await db.execute(text('DELETE FROM "Bookmark" WHERE "novelId" = :novel_id'), {"novel_id": novel_id}) + await db.execute(text('DELETE FROM "Comment" WHERE "novelId" = :novel_id'), {"novel_id": novel_id}) + await db.execute(text('DELETE FROM "NovelViewDaily" WHERE "novelId" = :novel_id'), {"novel_id": novel_id}) + deleted = (await db.execute(text('DELETE FROM "Novel" WHERE id = :id RETURNING id'), {"id": novel_id})).mappings().first() + return bool(deleted) + + +@app.get("/api/mod/series") +async def mod_list_series( + 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") + rows = ( + await db.execute( + text( + 'SELECT s.id, s.name, s.slug, s.description, COUNT(n.id)::int AS novels_count ' + 'FROM "Series" s LEFT JOIN "Novel" n ON n."seriesId" = s.id ' + 'GROUP BY s.id ORDER BY s.name ASC' + ) + ) + ).mappings().all() + return [ + { + "id": r["id"], + "name": r["name"], + "slug": r["slug"], + "description": r.get("description"), + "_count": {"novels": int(r.get("novels_count") or 0)}, + } + for r in rows + ] + + +@app.post("/api/mod/series") +async def mod_create_series( + payload: ModSeriesPayload, + 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") + name = " ".join((payload.name or "").split()).strip() + if not name: + raise HTTPException(status_code=400, detail="Tên series không hợp lệ") + slug = _norm_title(name).replace(" ", "-")[:120] or _new_id("series_") + existing = ( + await db.execute( + text('SELECT id, name, slug, description FROM "Series" WHERE lower(name)=:name OR slug=:slug LIMIT 1'), + {"name": name.lower(), "slug": slug}, + ) + ).mappings().first() + if existing: + return dict(existing) + slug = await _ensure_unique_slug(db, table="Series", slug=slug) + row = ( + await db.execute( + text('INSERT INTO "Series" (id, name, slug, description, "createdAt", "updatedAt") VALUES (:id,:name,:slug,:description,NOW(),NOW()) RETURNING id, name, slug, description'), + {"id": _new_id("series_"), "name": name, "slug": slug, "description": payload.description}, + ) + ).mappings().first() + await db.commit() + return dict(row) if row else {} + + +@app.put("/api/mod/series") +async def mod_update_series( + payload: ModSeriesPayload, + 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") + sid = str(payload.id or "").strip() + if not sid: + raise HTTPException(status_code=400, detail="id là bắt buộc") + name = " ".join((payload.name or "").split()).strip() + if not name: + raise HTTPException(status_code=400, detail="Tên series không hợp lệ") + slug = _norm_title(name).replace(" ", "-")[:120] or sid + slug = await _ensure_unique_slug(db, table="Series", slug=slug, current_id=sid) + row = ( + await db.execute( + text('UPDATE "Series" SET name=:name, slug=:slug, description=:description, "updatedAt"=NOW() WHERE id=:id RETURNING id, name, slug, description'), + {"id": sid, "name": name, "slug": slug, "description": payload.description}, + ) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Series not found") + await db.commit() + return dict(row) + + +@app.delete("/api/mod/series") +async def mod_delete_series( + 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") + await db.execute(text('UPDATE "Novel" SET "seriesId" = NULL, "updatedAt" = NOW() WHERE "seriesId" = :id'), {"id": id}) + row = (await db.execute(text('DELETE FROM "Series" WHERE id = :id RETURNING id'), {"id": id})).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Series not found") + await db.commit() + return {"id": id, "deleted": True} + + +@app.get("/api/mod/truyen") +async def mod_list_novels( + 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") + rows = ( + await db.execute( + text( + 'SELECT n.id, n.title, n.slug, n."authorName", n.status, n."totalChapters", n."coverUrl", ' + '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, n."createdAt" DESC' + ) + ) + ).mappings().all() + return [ + { + "id": r["id"], + "title": r["title"], + "slug": r["slug"], + "authorName": r.get("authorName") or "", + "status": r.get("status") or "Đang ra", + "totalChapters": int(r.get("totalChapters") or 0), + "coverUrl": r.get("coverUrl"), + "series": ( + {"id": r["series_id"], "name": r["series_name"], "slug": r["series_slug"]} + if r.get("series_id") + else None + ), + } + for r in rows + ] + + +@app.get("/api/mod/truyen/{novel_id}") +async def mod_get_novel_detail( + novel_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( + 'SELECT n.id, n.title, n.slug, n."authorName", n."originalTitle", n."originalAuthorName", ' + 'n.description, n."coverUrl", n.status, n."totalChapters", ' + '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.id = :id LIMIT 1' + ), + {"id": novel_id}, + ) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Novel not found") + genre_rows = ( + 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" = :id ORDER BY g.name ASC'), + {"id": novel_id}, + ) + ).mappings().all() + return { + "id": row["id"], + "title": row["title"], + "slug": row["slug"], + "authorName": row.get("authorName") or "", + "originalTitle": row.get("originalTitle") or "", + "originalAuthorName": row.get("originalAuthorName") or "", + "description": row.get("description") or "", + "coverUrl": row.get("coverUrl"), + "status": row.get("status") or "Đang ra", + "totalChapters": int(row.get("totalChapters") or 0), + "series": ( + {"id": row["series_id"], "name": row["series_name"], "slug": row["series_slug"]} + if row.get("series_id") + else None + ), + "genres": [dict(g) for g in genre_rows], + } + + +@app.post("/api/mod/truyen") +async def mod_create_novel( + payload: ModNovelPayload, + 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") + title = " ".join((payload.title or "").split()).strip() + author_name = " ".join((payload.authorName or "").split()).strip() + if not title or not author_name: + raise HTTPException(status_code=400, detail="title và authorName là bắt buộc") + + slug_base = _norm_title(title).replace(" ", "-")[:120] or _new_id("n_") + slug = await _ensure_unique_slug(db, table="Novel", slug=slug_base) + resolved_series_id = await _resolve_series_id(db, series_id=payload.seriesId, series_name=payload.seriesName) + + novel_id = _new_id("n_") + row = ( + await db.execute( + text( + 'INSERT INTO "Novel" (id, title, slug, "authorName", "originalTitle", "originalAuthorName", description, "coverUrl", status, "seriesId", "totalChapters", views, rating, "ratingCount", "bookmarkCount", "createdAt", "updatedAt") ' + 'VALUES (:id,:title,:slug,:author,:original_title,:original_author,:description,:cover_url,:status,:series_id,0,0,0,0,0,NOW(),NOW()) ' + 'RETURNING id, title, slug, "authorName", status, "totalChapters", "coverUrl"' + ), + { + "id": novel_id, + "title": title, + "slug": slug, + "author": author_name, + "original_title": (payload.originalTitle or "").strip() or None, + "original_author": (payload.originalAuthorName or "").strip() or None, + "description": (payload.description or "").strip(), + "cover_url": (payload.coverUrl or "").strip() or None, + "status": (payload.status or "Đang ra").strip() or "Đang ra", + "series_id": resolved_series_id, + }, + ) + ).mappings().first() + await _set_novel_genres(db, novel_id, payload.genreIds or []) + await db.commit() + return dict(row) if row else {} + + +@app.put("/api/mod/truyen") +async def mod_update_novel( + payload: ModNovelPayload, + db: AsyncSession = Depends(get_db_session), + user: dict = Depends(require_current_user), +): + if user.get("role") not in ("MOD", "ADMIN"): + raise HTTPException(status_code=403, detail="Forbidden") + novel_id = str(payload.id or "").strip() + if not novel_id: + raise HTTPException(status_code=400, detail="id là bắt buộc") + + current = ( + await db.execute(text('SELECT id, title, slug, "seriesId" FROM "Novel" WHERE id = :id LIMIT 1'), {"id": novel_id}) + ).mappings().first() + if not current: + raise HTTPException(status_code=404, detail="Novel not found") + + next_title = " ".join((payload.title or str(current.get("title") or "")).split()).strip() + next_author = " ".join((payload.authorName or "").split()).strip() + if not next_title: + raise HTTPException(status_code=400, detail="title không hợp lệ") + if payload.authorName is not None and not next_author: + raise HTTPException(status_code=400, detail="authorName không hợp lệ") + + slug_base = _norm_title(next_title).replace(" ", "-")[:120] or str(current.get("slug") or _new_id("n_")) + next_slug = await _ensure_unique_slug(db, table="Novel", slug=slug_base, current_id=novel_id) + + use_series_name = payload.seriesName is not None and str(payload.seriesName).strip() != "" + if use_series_name: + next_series_id = await _resolve_series_id(db, series_id=None, series_name=payload.seriesName) + elif payload.seriesId is not None: + next_series_id = await _resolve_series_id(db, series_id=payload.seriesId, series_name=None) + else: + next_series_id = current.get("seriesId") + + row = ( + await db.execute( + text( + 'UPDATE "Novel" SET ' + 'title = :title, slug = :slug, ' + '"authorName" = COALESCE(:author_name, "authorName"), ' + '"originalTitle" = :original_title, "originalAuthorName" = :original_author, ' + 'description = :description, "coverUrl" = :cover_url, ' + 'status = COALESCE(:status, status), "seriesId" = :series_id, "updatedAt" = NOW() ' + 'WHERE id = :id ' + 'RETURNING id, title, slug, "authorName", status, "totalChapters", "coverUrl"' + ), + { + "id": novel_id, + "title": next_title, + "slug": next_slug, + "author_name": next_author or None, + "original_title": (payload.originalTitle or "").strip() or None, + "original_author": (payload.originalAuthorName or "").strip() or None, + "description": (payload.description or "").strip(), + "cover_url": (payload.coverUrl or "").strip() or None, + "status": (payload.status or "").strip() or None, + "series_id": next_series_id, + }, + ) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Novel not found") + if payload.genreIds is not None: + await _set_novel_genres(db, novel_id, payload.genreIds) + await db.commit() + return dict(row) + + +@app.delete("/api/mod/truyen") +async def mod_delete_novel( + 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") + deleted = await _delete_novel_by_id(db, id) + if not deleted: + raise HTTPException(status_code=404, detail="Novel not found") + await db.commit() + return {"id": id, "deleted": True} + + +@app.post("/api/mod/truyen/bulk") +async def mod_bulk_novel_action( + payload: dict[str, Any] = Body(...), + db: AsyncSession = Depends(get_db_session), + user: dict = Depends(require_current_user), +): + if user.get("role") not in ("MOD", "ADMIN"): + raise HTTPException(status_code=403, detail="Forbidden") + action = str(payload.get("action") or "delete").strip().lower() or "delete" + raw_ids = payload.get("ids") + if not isinstance(raw_ids, list): + raw_ids = payload.get("novelIds") + ids = [str(i).strip() for i in (raw_ids or []) if str(i).strip()] + if action != "delete": + raise HTTPException(status_code=400, detail="Unsupported bulk action") + if not ids: + raise HTTPException(status_code=400, detail="ids is required") + deleted_count = 0 + for novel_id in ids: + if await _delete_novel_by_id(db, novel_id): + deleted_count += 1 + await db.commit() + return {"action": action, "deletedCount": deleted_count} + + +@app.get("/api/mod/truyen/missing") +async def mod_list_missing_novels( + missing: str = "author,cover,description,genres", + q: 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") + + keys = {k.strip() for k in missing.split(",") if k.strip()} + filters: list[str] = [] + if "author" in keys: + filters.append('(COALESCE(TRIM(n."authorName"), \'\') = \'\')') + if "cover" in keys: + filters.append('(COALESCE(TRIM(n."coverUrl"), \'\') = \'\')') + if "description" in keys: + filters.append('(COALESCE(TRIM(n.description), \'\') = \'\')') + if "genres" in keys: + filters.append('(NOT EXISTS (SELECT 1 FROM "NovelGenre" ng2 WHERE ng2."novelId" = n.id))') + + where_parts: list[str] = [] + params: dict[str, Any] = {} + if filters: + where_parts.append(f"({' OR '.join(filters)})") + if q.strip(): + params["q"] = f"%{q.strip()}%" + where_parts.append('(n.title ILIKE :q OR n.slug ILIKE :q OR n."authorName" ILIKE :q OR s.name ILIKE :q)') + where_sql = f"WHERE {' AND '.join(where_parts)}" if where_parts else "" + + rows = ( + await db.execute( + text( + 'SELECT n.id, n.title, n.slug, n."authorName", n."coverUrl", n.description, n."totalChapters", n."updatedAt", ' + '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" ' + f'{where_sql} ' + 'ORDER BY n."updatedAt" DESC, n.title ASC LIMIT 2000' + ), + params, + ) + ).mappings().all() + + novel_ids = [str(r["id"]) for r in rows] + genre_map: dict[str, list[dict[str, Any]]] = {nid: [] for nid in novel_ids} + if novel_ids: + genre_rows = ( + await db.execute( + text('SELECT ng."novelId", g.id, g.name, g.slug FROM "NovelGenre" ng JOIN "Genre" g ON g.id = ng."genreId" WHERE ng."novelId" = ANY(:novel_ids) ORDER BY g.name ASC'), + {"novel_ids": novel_ids}, + ) + ).mappings().all() + for g in genre_rows: + genre_map[str(g["novelId"])].append({"id": g["id"], "name": g["name"], "slug": g["slug"]}) + + items: list[dict[str, Any]] = [] + for r in rows: + genres = genre_map.get(str(r["id"]), []) + author_blank = not str(r.get("authorName") or "").strip() + cover_blank = not str(r.get("coverUrl") or "").strip() + desc_blank = not str(r.get("description") or "").strip() + genre_blank = len(genres) == 0 + items.append( + { + "id": r["id"], + "title": r["title"], + "slug": r["slug"], + "authorName": r.get("authorName") or "", + "coverUrl": r.get("coverUrl"), + "description": r.get("description") or "", + "totalChapters": int(r.get("totalChapters") or 0), + "updatedAt": _iso(r.get("updatedAt")), + "series": ( + {"id": r["series_id"], "name": r["series_name"], "slug": r["series_slug"]} + if r.get("series_id") + else None + ), + "genres": genres, + "missing": { + "author": author_blank, + "cover": cover_blank, + "description": desc_blank, + "genres": genre_blank, + }, + } + ) + + return {"items": items} + + +@app.patch("/api/mod/truyen/missing") +async def mod_patch_missing_novels( + payload: ModNovelMissingBulkPatchPayload, + 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") + + updated_count = 0 + failures: list[dict[str, Any]] = [] + + for item in payload.updates: + novel_id = str(item.id or "").strip() + if not novel_id: + failures.append({"id": item.id, "error": "id không hợp lệ"}) + continue + try: + exists = (await db.execute(text('SELECT id FROM "Novel" WHERE id = :id LIMIT 1'), {"id": novel_id})).mappings().first() + if not exists: + failures.append({"id": novel_id, "error": "Novel not found"}) + continue + + await db.execute( + text( + 'UPDATE "Novel" SET ' + '"authorName" = COALESCE(:author_name, "authorName"), ' + '"coverUrl" = COALESCE(:cover_url, "coverUrl"), ' + 'description = COALESCE(:description, description), ' + '"updatedAt" = NOW() ' + 'WHERE id = :id' + ), + { + "id": novel_id, + "author_name": (item.authorName or "").strip() or None, + "cover_url": (item.coverUrl or "").strip() or None, + "description": (item.description or "").strip() or None, + }, + ) + if item.genreIds is not None: + await _set_novel_genres(db, novel_id, item.genreIds) + updated_count += 1 + except Exception as exc: + failures.append({"id": novel_id, "error": str(exc)}) + + await db.commit() + return { + "updatedCount": updated_count, + "failureCount": len(failures), + "failures": failures, + } + + +@app.get("/api/mod/overview") +async def mod_overview( + db: AsyncSession = Depends(get_db_session), + user: dict = Depends(require_current_user), +): + if user.get("role") not in ("MOD", "ADMIN"): + raise HTTPException(status_code=403, detail="Forbidden") + novel_count = (await db.execute(text('SELECT COUNT(*)::int FROM "Novel"'))).scalar_one() + total_views = (await db.execute(text('SELECT COALESCE(SUM(views),0)::int FROM "Novel"'))).scalar_one() + comment_count = (await db.execute(text('SELECT COUNT(*)::int FROM "Comment"'))).scalar_one() + series_count = (await db.execute(text('SELECT COUNT(*)::int FROM "Series"'))).scalar_one() + return { + "novelCount": int(novel_count or 0), + "totalViews": int(total_views or 0), + "commentCount": int(comment_count or 0), + "seriesCount": int(series_count or 0), + } + + +async def _ensure_editor_recommendation_table(db: AsyncSession) -> None: + await db.execute( + text( + '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()' + ')' + ) + ) + await db.execute( + text('CREATE INDEX IF NOT EXISTS "EditorRecommendationDoc_novel_idx" ON "EditorRecommendationDoc"("novelId")') + ) + await db.commit() + + +@app.get("/api/mod/de-cu") +async def mod_list_recommendations( + q: 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") + await _ensure_editor_recommendation_table(db) + + docs = ( + await db.execute( + text('SELECT id, "editorId", "novelId", "createdAt" FROM "EditorRecommendationDoc" ORDER BY "createdAt" DESC LIMIT 5000') + ) + ).mappings().all() + novel_ids = list({str(d.get("novelId") or "") for d in docs if d.get("novelId")}) + editor_ids = list({str(d.get("editorId") or "") for d in docs if d.get("editorId")}) + + novel_map: dict[str, dict[str, Any]] = {} + if novel_ids: + rows = ( + await db.execute( + text('SELECT id, title, slug, "authorName", "coverUrl", status, "totalChapters" FROM "Novel" WHERE id = ANY(:ids)'), + {"ids": novel_ids}, + ) + ).mappings().all() + novel_map = {str(r["id"]): dict(r) for r in rows} + + editor_map: dict[str, str] = {} + if editor_ids: + rows = ( + await db.execute( + text('SELECT id, name FROM "User" WHERE id = ANY(:ids)'), + {"ids": editor_ids}, + ) + ).mappings().all() + editor_map = {str(r["id"]): str(r.get("name") or "Biên tập viên") for r in rows} + + rec_count_map: dict[str, int] = {} + for d in docs: + nid = str(d.get("novelId") or "") + if not nid: + continue + rec_count_map[nid] = rec_count_map.get(nid, 0) + 1 + + items: list[dict[str, Any]] = [] + for d in docs: + nid = str(d.get("novelId") or "") + if nid not in novel_map: + continue + eid = str(d.get("editorId") or "") + items.append( + { + "id": str(d.get("id")), + "createdAt": _iso(d.get("createdAt")), + "recommendCount": int(rec_count_map.get(nid, 0)), + "novel": novel_map[nid], + "editor": {"id": eid, "name": editor_map.get(eid, "Biên tập viên")}, + } + ) + + summary = [ + {"novel": novel_map[nid], "recommendCount": int(count)} + for nid, count in rec_count_map.items() + if nid in novel_map + ] + summary.sort(key=lambda x: (-int(x["recommendCount"]), str(x["novel"].get("title") or ""))) + + params: dict[str, Any] = {} + where_sql = "" + if q.strip(): + params["q"] = f"%{q.strip()}%" + where_sql = 'WHERE title ILIKE :q OR slug ILIKE :q OR "authorName" ILIKE :q' + candidates_rows = ( + await db.execute( + text( + f'SELECT id, title, slug, "authorName", "coverUrl", status, "totalChapters" FROM "Novel" ' + f'{where_sql} ORDER BY "updatedAt" DESC LIMIT 100' + ), + params, + ) + ).mappings().all() + my_editor_id = str(user.get("id") or "") + my_novel_ids = {str(d.get("novelId") or "") for d in docs if str(d.get("editorId") or "") == my_editor_id} + candidates = [] + for r in candidates_rows: + nid = str(r["id"]) + candidates.append( + { + **dict(r), + "alreadyRecommended": nid in my_novel_ids, + "recommendCount": int(rec_count_map.get(nid, 0)), + } + ) + + my_count = sum(1 for d in docs if str(d.get("editorId") or "") == my_editor_id) + return { + "items": items, + "summary": summary, + "candidates": candidates, + "myNovelIds": list(my_novel_ids), + "currentUser": { + "id": my_editor_id, + "role": str(user.get("role") or "USER"), + "recommendationCount": my_count, + "maxRecommendationCount": 5, + }, + } + + +@app.post("/api/mod/de-cu") +async def mod_create_recommendation( + payload: dict[str, Any] = Body(...), + db: AsyncSession = Depends(get_db_session), + user: dict = Depends(require_current_user), +): + if user.get("role") not in ("MOD", "ADMIN"): + raise HTTPException(status_code=403, detail="Forbidden") + await _ensure_editor_recommendation_table(db) + novel_id = str(payload.get("novelId") or "").strip() + if not novel_id: + raise HTTPException(status_code=400, detail="novelId is required") + novel_exists = (await db.execute(text('SELECT id FROM "Novel" WHERE id = :id LIMIT 1'), {"id": novel_id})).mappings().first() + if not novel_exists: + raise HTTPException(status_code=404, detail="Novel not found") + editor_id = str(user.get("id") or "") + existing = ( + await db.execute( + text('SELECT id FROM "EditorRecommendationDoc" WHERE "editorId" = :editor_id AND "novelId" = :novel_id LIMIT 1'), + {"editor_id": editor_id, "novel_id": novel_id}, + ) + ).mappings().first() + if existing: + raise HTTPException(status_code=409, detail="Bạn đã đề cử truyện này") + my_count = ( + await db.execute( + text('SELECT COUNT(*)::int FROM "EditorRecommendationDoc" WHERE "editorId" = :editor_id'), + {"editor_id": editor_id}, + ) + ).scalar_one() + if str(user.get("role") or "") != "ADMIN" and int(my_count or 0) >= 5: + raise HTTPException(status_code=400, detail="Đã đạt giới hạn đề cử") + rec_id = _new_id("erec_") + await db.execute( + text('INSERT INTO "EditorRecommendationDoc" (id, "editorId", "novelId", "createdAt") VALUES (:id,:editor_id,:novel_id,NOW())'), + {"id": rec_id, "editor_id": editor_id, "novel_id": novel_id}, + ) + await db.commit() + return {"id": rec_id, "novelId": novel_id} + + +@app.delete("/api/mod/de-cu") +async def mod_delete_recommendation( + 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") + await _ensure_editor_recommendation_table(db) + row = ( + await db.execute( + text('SELECT id, "editorId" FROM "EditorRecommendationDoc" WHERE id = :id LIMIT 1'), + {"id": id}, + ) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Recommendation not found") + is_admin = str(user.get("role") or "") == "ADMIN" + if not is_admin and str(row.get("editorId") or "") != str(user.get("id") or ""): + raise HTTPException(status_code=403, detail="Forbidden") + await db.execute(text('DELETE FROM "EditorRecommendationDoc" WHERE id = :id'), {"id": id}) + await db.commit() + return {"id": id, "deleted": True} + + +async def _upsert_chapter_content(chapter_id: str, novel_id: str, number: int, content: str, db: AsyncSession) -> None: + txt_href = f"novel-{novel_id}/{number}.txt" + raw_href = f"novel-{novel_id}/{number}.raw.html" + txt = str(content or "") + await asyncio.to_thread(storage.write_text, txt_href, txt) + await asyncio.to_thread(storage.write_text, raw_href, txt) + h = hashlib.sha256(txt.encode("utf-8")).hexdigest() + await db.execute( + text( + 'INSERT INTO "ChapterContentRef" ("chapterId", "txtHref", "rawHtmlHref", "contentHash") ' + 'VALUES (:id,:txt,:raw,:hash) ' + 'ON CONFLICT ("chapterId") DO UPDATE SET "txtHref"=EXCLUDED."txtHref", "rawHtmlHref"=EXCLUDED."rawHtmlHref", "contentHash"=EXCLUDED."contentHash", "updatedAt"=NOW()' + ), + {"id": chapter_id, "txt": txt_href, "raw": raw_href, "hash": h}, + ) + + +@app.get("/api/mod/chuong") +async def mod_list_chapters( + novelId: str, + page: int = Query(default=1, ge=1), + limit: int = Query(default=50, ge=1, le=200), + 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") + skip = (page - 1) * limit + rows = ( + await db.execute( + text('SELECT id, number, title, views, "createdAt" FROM "ChapterMeta" WHERE "novelId" = :novel_id ORDER BY number ASC OFFSET :skip LIMIT :limit'), + {"novel_id": novelId, "skip": skip, "limit": limit}, + ) + ).mappings().all() + total = (await db.execute(text('SELECT COUNT(*)::int FROM "ChapterMeta" WHERE "novelId" = :novel_id'), {"novel_id": novelId})).scalar_one() + return { + "chapters": [ + { + "id": r["id"], + "_id": r["id"], + "number": int(r.get("number") or 0), + "title": r.get("title") or "", + "views": int(r.get("views") or 0), + "createdAt": _iso(r.get("createdAt")), + "volumeNumber": None, + "volumeTitle": None, + "volumeChapterNumber": None, + } + for r in rows + ], + "totalChapters": int(total or 0), + "totalPages": (int(total or 0) + limit - 1) // limit if total else 0, + "currentPage": page, + } + + +@app.get("/api/mod/chuong/{chapter_id}") +async def mod_get_chapter_detail( + chapter_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('SELECT id, "novelId", number, title, views, "createdAt" FROM "ChapterMeta" WHERE id = :id LIMIT 1'), + {"id": chapter_id}, + ) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Chapter not found") + content = await _resolve_chapter_content(chapter_id, db) or "" + return { + "id": row["id"], + "_id": row["id"], + "novelId": row["novelId"], + "number": int(row.get("number") or 0), + "title": row.get("title") or "", + "content": content, + "views": int(row.get("views") or 0), + "createdAt": _iso(row.get("createdAt")), + "volumeNumber": None, + "volumeTitle": None, + "volumeChapterNumber": None, + } + + +@app.post("/api/mod/chuong") +async def mod_create_chapter( + payload: ModChapterPayload, + db: AsyncSession = Depends(get_db_session), + user: dict = Depends(require_current_user), +): + if user.get("role") not in ("MOD", "ADMIN"): + raise HTTPException(status_code=403, detail="Forbidden") + novel_exists = (await db.execute(text('SELECT id FROM "Novel" WHERE id = :id LIMIT 1'), {"id": payload.novelId})).mappings().first() + if not novel_exists: + raise HTTPException(status_code=404, detail="Novel not found") + existing = ( + await db.execute( + text('SELECT id FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number = :number LIMIT 1'), + {"novel_id": payload.novelId, "number": payload.number}, + ) + ).mappings().first() + if existing: + raise HTTPException(status_code=409, detail="Chapter number already exists") + cid = _new_id("cmeta_") + await db.execute( + text('INSERT INTO "ChapterMeta" (id, "novelId", number, title, views, "createdAt") VALUES (:id,:novel,:num,:title,0,NOW())'), + {"id": cid, "novel": payload.novelId, "num": payload.number, "title": payload.title.strip()}, + ) + await _upsert_chapter_content(cid, payload.novelId, payload.number, payload.content, db) + await db.execute(text('UPDATE "Novel" SET "totalChapters" = (SELECT COUNT(*) FROM "ChapterMeta" WHERE "novelId" = :novel_id), "updatedAt" = NOW() WHERE id = :novel_id'), {"novel_id": payload.novelId}) + await db.commit() + return {"id": cid, "created": True} + + +@app.put("/api/mod/chuong") +async def mod_update_chapter( + payload: ModChapterPayload, + db: AsyncSession = Depends(get_db_session), + user: dict = Depends(require_current_user), +): + if user.get("role") not in ("MOD", "ADMIN"): + raise HTTPException(status_code=403, detail="Forbidden") + chapter_id = str(payload.id or "").strip() + if not chapter_id: + raise HTTPException(status_code=400, detail="id is required") + row = ( + await db.execute( + text('SELECT id, "novelId" FROM "ChapterMeta" WHERE id = :id LIMIT 1'), + {"id": chapter_id}, + ) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Chapter not found") + await db.execute( + text('UPDATE "ChapterMeta" SET number = :num, title = :title WHERE id = :id'), + {"id": chapter_id, "num": payload.number, "title": payload.title.strip()}, + ) + await _upsert_chapter_content(chapter_id, str(row["novelId"]), payload.number, payload.content, db) + await db.execute(text('UPDATE "Novel" SET "totalChapters" = (SELECT COUNT(*) FROM "ChapterMeta" WHERE "novelId" = :novel_id), "updatedAt" = NOW() WHERE id = :novel_id'), {"novel_id": row["novelId"]}) + await db.commit() + return {"id": chapter_id, "updated": True} + + +@app.delete("/api/mod/chuong") +async def mod_delete_chapter( + id: str, + novelId: 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") + await db.execute(text('DELETE FROM "ChapterContentRef" WHERE "chapterId" = :id'), {"id": id}) + row = ( + await db.execute( + text('DELETE FROM "ChapterMeta" WHERE id = :id AND "novelId" = :novel_id RETURNING id'), + {"id": id, "novel_id": novelId}, + ) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Chapter not found") + await db.execute(text('UPDATE "Novel" SET "totalChapters" = (SELECT COUNT(*) FROM "ChapterMeta" WHERE "novelId" = :novel_id), "updatedAt" = NOW() WHERE id = :novel_id'), {"novel_id": novelId}) + await db.commit() + return {"id": id, "deleted": True} + + +@app.post("/api/mod/chuong/bulk-delete") +async def mod_bulk_delete_chapters( + payload: ModChapterBulkDeletePayload, + 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") + from_num = min(payload.fromNumber, payload.toNumber) + to_num = max(payload.fromNumber, payload.toNumber) + ids = ( + await db.execute( + text('SELECT id FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number BETWEEN :from_num AND :to_num'), + {"novel_id": payload.novelId, "from_num": from_num, "to_num": to_num}, + ) + ).mappings().all() + chapter_ids = [str(r["id"]) for r in ids] + if chapter_ids: + await db.execute(text('DELETE FROM "ChapterContentRef" WHERE "chapterId" = ANY(:ids)'), {"ids": chapter_ids}) + deleted_count = ( + await db.execute( + text('DELETE FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number BETWEEN :from_num AND :to_num RETURNING id'), + {"novel_id": payload.novelId, "from_num": from_num, "to_num": to_num}, + ) + ).mappings().all() + await db.execute(text('UPDATE "Novel" SET "totalChapters" = (SELECT COUNT(*) FROM "ChapterMeta" WHERE "novelId" = :novel_id), "updatedAt" = NOW() WHERE id = :novel_id'), {"novel_id": payload.novelId}) + await db.commit() + return {"deletedCount": len(deleted_count)} + + +@app.put("/api/mod/chuong/optimize") +async def mod_optimize_chapters( + payload: ModChapterOptimizePayload, + 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") + modified = 0 + for item in payload.updates: + row = ( + await db.execute( + text('SELECT id FROM "ChapterMeta" WHERE id = :id AND "novelId" = :novel_id LIMIT 1'), + {"id": item.id, "novel_id": payload.novelId}, + ) + ).mappings().first() + if not row: + continue + await db.execute( + text('UPDATE "ChapterMeta" SET number = :number, title = :title WHERE id = :id'), + {"id": item.id, "number": item.number, "title": item.title}, + ) + modified += 1 + await db.execute(text('UPDATE "Novel" SET "totalChapters" = (SELECT COUNT(*) FROM "ChapterMeta" WHERE "novelId" = :novel_id), "updatedAt" = NOW() WHERE id = :novel_id'), {"novel_id": payload.novelId}) + await db.commit() + return {"modifiedCount": modified} + + +@app.post("/api/mod/chuong/global-replace") +async def mod_global_replace_chapters( + payload: ModChapterGlobalReplacePayload, + 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") + rows = ( + await db.execute( + text('SELECT id, number, title FROM "ChapterMeta" WHERE "novelId" = :novel_id ORDER BY number ASC'), + {"novel_id": payload.novelId}, + ) + ).mappings().all() + flags = 0 if payload.matchCase else re.IGNORECASE + previews: list[dict[str, Any]] = [] + updated = 0 + for r in rows: + cid = str(r["id"]) + content = await _resolve_chapter_content(cid, db) or "" + new_content = content + if payload.action == "replace": + find_text = str(payload.findText or "") + if not find_text: + continue + pattern = re.compile(re.escape(find_text), flags) + new_content = pattern.sub(str(payload.replaceText or ""), content) + elif payload.action == "trash": + for tw in payload.trashWords: + if not str(tw).strip(): + continue + pattern = re.compile(re.escape(str(tw)), flags) + new_content = pattern.sub("", new_content) + else: + raise HTTPException(status_code=400, detail="Unsupported action") + + if new_content == content: + continue + if payload.preview: + previews.append( + { + "chapterId": cid, + "number": int(r.get("number") or 0), + "title": str(r.get("title") or ""), + "snippet": new_content[:240], + } + ) + if len(previews) >= 50: + break + else: + await _upsert_chapter_content(cid, payload.novelId, int(r.get("number") or 0), new_content, db) + updated += 1 + if payload.preview: + return {"previews": previews} + await db.commit() + return {"updatedChapters": updated} + + +@app.get("/api/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_current_user), +): + if user.get("role") not in ("MOD", "ADMIN"): + raise HTTPException(status_code=403, detail="Forbidden") + row = ( + await db.execute(text('SELECT "trashWords" FROM "Novel" WHERE id = :id LIMIT 1'), {"id": novel_id}) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Novel not found") + return {"trashWords": list(row.get("trashWords") or [])} + + +@app.put("/api/mod/truyen/{novel_id}/trash-words") +async def mod_set_trash_words( + novel_id: str, + payload: ModNovelTrashWordsPayload, + 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") + clean = [str(w).strip() for w in payload.trashWords if str(w).strip()] + row = ( + await db.execute( + text('UPDATE "Novel" SET "trashWords" = :words, "updatedAt" = NOW() WHERE id = :id RETURNING id'), + {"id": novel_id, "words": clean}, + ) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Novel not found") + await db.commit() + return {"novelId": novel_id, "trashWords": clean} + + +@app.post("/api/mod/upload-cover") +async def mod_upload_cover( + file: UploadFile = File(...), + user: dict = Depends(require_current_user), +): + if user.get("role") not in ("MOD", "ADMIN"): + raise HTTPException(status_code=403, detail="Forbidden") + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail="Empty file") + ext = ".jpg" + ct = (file.content_type or "").lower() + if "png" in ct: + ext = ".png" + elif "webp" in ct: + ext = ".webp" + elif "jpeg" in ct or "jpg" in ct: + ext = ".jpg" + url = _upload_cover_bytes_to_r2(content, ext, key_prefix=f"mod-cover-{_new_id()}") + if not url: + raise HTTPException(status_code=500, detail="Upload failed") + return {"url": url} + + +@app.post("/api/mod/epub") +async def mod_epub_upload( + file: UploadFile = File(...), + preview: str | None = Form(default=None), + splitMode: str | None = Form(default=None), + chapterRegex: str | None = Form(default=None), + title: str | None = Form(default=None), + authorName: str | None = Form(default=None), + description: str | None = Form(default=None), + seriesMode: str | None = Form(default=None), + seriesId: str | None = Form(default=None), + seriesName: str | None = Form(default=None), + replaceExisting: str | None = Form(default=None), + appendTargetNovelId: str | None = Form(default=None), + 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") + raw = await file.read() + if not raw: + raise HTTPException(status_code=400, detail="Empty EPUB") + + suffix = ".epub" + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + tmp.write(raw) + tmp_path = Path(tmp.name) + + try: + mode = "regex" if (splitMode or "").lower() == "regex" else "toc" + pattern = (chapterRegex or "").strip() or None + chapters = _epub_extract_with_mode(tmp_path, mode, pattern) + base_title = " ".join((title or Path(file.filename or "novel").stem).split()).strip() or "Untitled" + base_author = " ".join((authorName or "Unknown").split()).strip() or "Unknown" + base_desc = (description or "").strip() + has_cover = bool(_extract_epub_cover(tmp_path)) + + if str(preview or "").lower() == "true": + return { + "preview": True, + "fileName": file.filename or "upload.epub", + "splitMode": mode, + "detectedStructureType": "standard", + "hasCoverFromEpub": has_cover, + "parserInfo": { + "splitMode": mode, + "chapterRegexUsed": pattern, + "sourceSections": len(chapters), + "chaptersDetected": len(chapters), + "chaptersFinal": len(chapters), + "insertedMissingChapters": len([c for c in chapters if c.get("is_placeholder")]), + "detectedMaxChapterNumber": max([int(c.get("number") or 0) for c in chapters], default=0), + "detectedNumberAssignments": len([c for c in chapters if int(c.get("number") or 0) > 0]), + }, + "novel": { + "title": base_title, + "authorName": base_author, + "description": base_desc, + "detectedGenres": [], + "totalChapters": len(chapters), + }, + "chaptersPreview": [ + { + "number": int(c.get("number") or 0), + "title": str(c.get("title") or ""), + "isPlaceholder": bool(c.get("is_placeholder") or False), + "volumeNumber": None, + "volumeTitle": None, + "volumeChapterNumber": None, + "excerpt": str(c.get("txt") or "")[:200], + } + for c in chapters[:30] + ], + } + + target_novel_id = str(appendTargetNovelId or "").strip() + if target_novel_id: + exists = (await db.execute(text('SELECT id FROM "Novel" WHERE id = :id LIMIT 1'), {"id": target_novel_id})).mappings().first() + if not exists: + raise HTTPException(status_code=404, detail="Target novel not found") + added = 0 + replaced = 0 + for ch in chapters: + num = int(ch.get("number") or 0) + if num <= 0: + continue + existing_ch = ( + await db.execute( + text('SELECT id FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number = :num LIMIT 1'), + {"novel_id": target_novel_id, "num": num}, + ) + ).mappings().first() + if existing_ch: + await db.execute(text('UPDATE "ChapterMeta" SET title = :title WHERE id = :id'), {"id": existing_ch["id"], "title": str(ch.get("title") or f"Chapter {num}")}) + if not bool(ch.get("is_placeholder") or False): + await _upsert_chapter_content(str(existing_ch["id"]), target_novel_id, num, str(ch.get("txt") or ""), db) + replaced += 1 + else: + cid = _new_id("cmeta_") + await db.execute(text('INSERT INTO "ChapterMeta" (id, "novelId", number, title, views, "createdAt") VALUES (:id,:novel,:num,:title,0,NOW())'), {"id": cid, "novel": target_novel_id, "num": num, "title": str(ch.get("title") or f"Chapter {num}")}) + if not bool(ch.get("is_placeholder") or False): + await _upsert_chapter_content(cid, target_novel_id, num, str(ch.get("txt") or ""), db) + added += 1 + await db.execute(text('UPDATE "Novel" SET "totalChapters" = (SELECT COUNT(*) FROM "ChapterMeta" WHERE "novelId" = :novel_id), "updatedAt" = NOW() WHERE id = :novel_id'), {"novel_id": target_novel_id}) + await db.commit() + return { + "novelId": target_novel_id, + "parserInfo": {"chaptersFinal": len(chapters)}, + "added": added, + "replaced": replaced, + } + + existing_by_title = ( + await db.execute( + text('SELECT id, title, slug FROM "Novel" WHERE lower(title) = :title LIMIT 1'), + {"title": base_title.lower()}, + ) + ).mappings().first() + should_replace = str(replaceExisting or "").lower() in {"1", "true", "yes", "on"} + if existing_by_title and not should_replace: + return Response( + content=json.dumps( + { + "code": "DUPLICATE_TITLE", + "error": "Truyện đã tồn tại", + "canReplace": True, + "existingNovel": { + "id": existing_by_title["id"], + "title": existing_by_title["title"], + "slug": existing_by_title["slug"], + }, + } + ), + status_code=409, + media_type="application/json", + ) + + target_series_id: str | None = None + sm = str(seriesMode or "none").lower() + if sm == "existing": + target_series_id = await _resolve_series_id(db, series_id=seriesId, series_name=None) + elif sm == "new": + target_series_id = await _resolve_series_id(db, series_id=None, series_name=seriesName) + + if existing_by_title and should_replace: + novel_id = str(existing_by_title["id"]) + await db.execute(text('DELETE FROM "ChapterContentRef" WHERE "chapterId" IN (SELECT id FROM "ChapterMeta" WHERE "novelId" = :novel_id)'), {"novel_id": novel_id}) + await db.execute(text('DELETE FROM "ChapterMeta" WHERE "novelId" = :novel_id'), {"novel_id": novel_id}) + await db.execute( + text('UPDATE "Novel" SET "authorName" = :author, description = :desc, "coverUrl" = COALESCE("coverUrl", :cover), "seriesId" = :series_id, "updatedAt" = NOW() WHERE id = :id'), + { + "id": novel_id, + "author": base_author, + "desc": base_desc, + "cover": None, + "series_id": target_series_id, + }, + ) + else: + novel_id = _new_id("n_") + slug = await _ensure_unique_slug(db, table="Novel", slug=_norm_title(base_title).replace(" ", "-")[:120] or novel_id) + await db.execute( + text('INSERT INTO "Novel" (id, title, slug, "authorName", description, "coverUrl", status, "seriesId", "totalChapters", views, rating, "ratingCount", "bookmarkCount", "createdAt", "updatedAt") VALUES (:id,:title,:slug,:author,:desc,:cover,:status,:series_id,0,0,0,0,0,NOW(),NOW())'), + { + "id": novel_id, + "title": base_title, + "slug": slug, + "author": base_author, + "desc": base_desc, + "cover": None, + "status": "Đang ra", + "series_id": target_series_id, + }, + ) + + for ch in chapters: + num = int(ch.get("number") or 0) + if num <= 0: + continue + cid = _new_id("cmeta_") + await db.execute(text('INSERT INTO "ChapterMeta" (id, "novelId", number, title, views, "createdAt") VALUES (:id,:novel,:num,:title,0,NOW())'), {"id": cid, "novel": novel_id, "num": num, "title": str(ch.get("title") or f"Chapter {num}")}) + if not bool(ch.get("is_placeholder") or False): + await _upsert_chapter_content(cid, novel_id, num, str(ch.get("txt") or ""), db) + + await db.execute(text('UPDATE "Novel" SET "totalChapters" = (SELECT COUNT(*) FROM "ChapterMeta" WHERE "novelId" = :novel_id), "updatedAt" = NOW() WHERE id = :novel_id'), {"novel_id": novel_id}) + await db.commit() + return { + "novelId": novel_id, + "replaced": bool(existing_by_title and should_replace), + "totalChapters": len(chapters), + "parserInfo": {"chaptersFinal": len(chapters)}, + } + finally: + try: + tmp_path.unlink(missing_ok=True) + except Exception: + pass + + +@app.get("/api/truyen") +async def get_novel_by_query( + slug: str, + db: AsyncSession = Depends(get_db_session), +): + row = ( + await db.execute( + text('SELECT id, title, slug, "authorName", "coverUrl", status, "totalChapters" FROM "Novel" WHERE id = :slug OR slug = :slug LIMIT 1'), + {"slug": slug}, + ) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Novel not found") + return dict(row) + + @app.get("/api/novels/browse") async def browse_novels( q: str = "", @@ -993,7 +2631,7 @@ async def get_novel_chapters( chapters = ( await db.execute( text( - 'SELECT id, number, title, views, "volumeNumber", "volumeTitle", "volumeChapterNumber", "createdAt" ' + 'SELECT id, number, title, views, "createdAt" ' 'FROM "ChapterMeta" WHERE "novelId" = :novel_id ORDER BY number ASC OFFSET :skip LIMIT :limit' ), {"novel_id": novel_id, "skip": skip, "limit": limit}, @@ -1010,9 +2648,6 @@ async def get_novel_chapters( "number": item.get("number"), "title": item.get("title"), "views": item.get("views", 0), - "volumeNumber": item.get("volumeNumber"), - "volumeTitle": item.get("volumeTitle"), - "volumeChapterNumber": item.get("volumeChapterNumber"), "createdAt": _iso(item.get("createdAt")), } for item in chapters @@ -1028,7 +2663,7 @@ async def get_chapter_by_number(novel_id: str, chapter_number: int, db: AsyncSes chapter = ( await db.execute( text( - 'SELECT id, "novelId", number, title, views, "volumeNumber", "volumeTitle", "volumeChapterNumber", "createdAt" ' + 'SELECT id, "novelId", number, title, views, "createdAt" ' 'FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number = :number LIMIT 1' ), {"novel_id": novel_id, "number": chapter_number}, @@ -1057,7 +2692,7 @@ async def get_chapter_by_number(novel_id: str, chapter_number: int, db: AsyncSes await db.commit() chapter_id = str(chapter.get("id")) - content = await _resolve_chapter_content(chapter_id, None, db) + content = await _resolve_chapter_content(chapter_id, db) return { "id": str(chapter.get("id")), @@ -1066,9 +2701,6 @@ async def get_chapter_by_number(novel_id: str, chapter_number: int, db: AsyncSes "title": chapter.get("title"), "content": content, "views": int(chapter.get("views") or 0) + 1, - "volumeNumber": chapter.get("volumeNumber"), - "volumeTitle": chapter.get("volumeTitle"), - "volumeChapterNumber": chapter.get("volumeChapterNumber"), "createdAt": _iso(chapter.get("createdAt")), "prevChapterNumber": prev_chapter.get("number") if prev_chapter else None, "nextChapterNumber": next_chapter.get("number") if next_chapter else None, @@ -1081,7 +2713,7 @@ async def get_chapter_detail(chapter_id: str, db: AsyncSession = Depends(get_db_ chapter = ( await db.execute( text( - 'SELECT id, "novelId", number, title, views, "volumeNumber", "volumeTitle", "volumeChapterNumber", "createdAt" ' + 'SELECT id, "novelId", number, title, views, "createdAt" ' 'FROM "ChapterMeta" WHERE id = :id LIMIT 1' ), {"id": chapter_id}, @@ -1103,7 +2735,7 @@ async def get_chapter_detail(chapter_id: str, db: AsyncSession = Depends(get_db_ ) ).mappings().first() - content = await _resolve_chapter_content(chapter_id, None, db) + content = await _resolve_chapter_content(chapter_id, db) return { "id": str(chapter.get("id")), @@ -1112,9 +2744,6 @@ async def get_chapter_detail(chapter_id: str, db: AsyncSession = Depends(get_db_ "title": chapter.get("title"), "content": content, "views": chapter.get("views", 0), - "volumeNumber": chapter.get("volumeNumber"), - "volumeTitle": chapter.get("volumeTitle"), - "volumeChapterNumber": chapter.get("volumeChapterNumber"), "createdAt": _iso(chapter.get("createdAt")), "prevChapterId": str(prev_chapter.get("id")) if prev_chapter else None, "prevChapterNumber": prev_chapter.get("number") if prev_chapter else None, @@ -1164,6 +2793,18 @@ class RatePayload(BaseModel): score: float = Field(ge=1, le=5) +class ModGenrePayload(BaseModel): + id: str | None = None + name: str + description: str | None = None + icon: str | None = None + + +class ModGenreMergePayload(BaseModel): + sourceId: str + targetId: str + + class SourceAssetApprovePayload(BaseModel): status: str = Field(pattern="^(approved|rejected|review_required)$") @@ -1188,6 +2829,14 @@ class ImportJobCompletePayload(BaseModel): force: bool = False +class ImportApplyPayload(BaseModel): + novelId: str + replaceMode: str = "none" # none | selected | range + selectedChapterNumbers: list[int] = [] + rangeStart: int | None = None + rangeEnd: int | None = None + + class SourceAssetUpsertPayload(BaseModel): path: str sha256: str @@ -1196,6 +2845,127 @@ class SourceAssetUpsertPayload(BaseModel): author: str | None = None +class SourceAssetReviewPayload(BaseModel): + title: str | None = None + author: str | None = None + shortDescription: str | None = None + genres: list[str] = [] + splitMode: str = Field(default="toc", pattern="^(toc|regex)$") + chapterStartPattern: str | None = None + targetMode: str = Field(default="new", pattern="^(new|existing)$") + novelId: str | None = None + replaceExisting: bool = False + + +class SourceAssetParsePreviewPayload(BaseModel): + splitMode: str = Field(default="toc", pattern="^(toc|regex)$") + chapterStartPattern: str | None = None + + +class SourceAssetStartImportPayload(BaseModel): + replaceExisting: bool = False + forceNovelId: str | None = None + splitMode: str = Field(default="toc", pattern="^(toc|regex)$") + chapterStartPattern: str | None = None + + +class SourceAssetAiSuggestPayload(BaseModel): + splitMode: str = Field(default="toc", pattern="^(toc|regex)$") + chapterStartPattern: str | None = None + + +class ModSeriesPayload(BaseModel): + id: str | None = None + name: str + description: str | None = None + + +class ModNovelPayload(BaseModel): + id: str | None = None + 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 + + +class ModNovelBulkPayload(BaseModel): + action: str + ids: list[str] + + +class ModNovelMissingUpdatePayload(BaseModel): + id: str + authorName: str | None = None + coverUrl: str | None = None + description: str | None = None + genreIds: list[str] | None = None + + +class ModNovelMissingBulkPatchPayload(BaseModel): + updates: list[ModNovelMissingUpdatePayload] + + +class ModChapterPayload(BaseModel): + id: str | None = None + novelId: str + number: int + title: str + content: str + volumeNumber: int | None = None + volumeTitle: str | None = None + volumeChapterNumber: int | None = None + + +class ModChapterBulkDeletePayload(BaseModel): + novelId: str + fromNumber: int + toNumber: int + + +class ModChapterOptimizeItem(BaseModel): + id: str + title: str + number: int + + +class ModChapterOptimizePayload(BaseModel): + novelId: str + updates: list[ModChapterOptimizeItem] + + +class ModChapterGlobalReplacePayload(BaseModel): + novelId: str + action: str + findText: str | None = None + replaceText: str | None = None + trashWords: list[str] = [] + matchCase: bool = False + preview: bool = False + + +class ModNovelTrashWordsPayload(BaseModel): + trashWords: list[str] = [] + + +def _norm_title(v: str) -> str: + s = (v or "").strip().lower() + frm = "áàảãạăắằẳẵặâấầẩẫậéèẻẽẹêếềểễệíìỉĩịóòỏõọôốồổỗộơớờởỡợúùủũụưứừửữựýỳỷỹỵđ" + to = "aaaaaaaaaaaaaaaaaeeeeeeeeeeeiiiiiooooooooooooooooouuuuuuuuuuuyyyyyd" + s = s.translate(str.maketrans(frm, to)) + s = "".join(ch for ch in s if ch.isalnum() or ch.isspace()) + return " ".join(s.split()) + + +def _title_score(a: str, b: str) -> float: + return SequenceMatcher(None, _norm_title(a), _norm_title(b)).ratio() + + def _asset_file_sha256(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: @@ -1207,6 +2977,89 @@ def _asset_file_sha256(path: Path) -> str: return h.hexdigest() +def _derive_chapter_title(txt: str, fallback: str, number: int) -> str: + lines = [line.strip().lstrip("#").strip() for line in txt.splitlines() if line.strip()] + chapter_re = re.compile(r"^(?:chuong|ch\.?|chapter|hoi|quyen|phan|tap)\s*\d+(?:[\.:\-\)]\s*|\s+).+", re.IGNORECASE) + chapter_num_re = re.compile(r"^(?:chuong|ch\.?|chapter|hoi|quyen|phan|tap)\s*\d+", re.IGNORECASE) + + for line in lines[:12]: + normalized = _norm_title(line) + if not normalized: + continue + if chapter_re.match(normalized): + return line + if chapter_num_re.match(normalized): + return line + + if lines: + first = lines[0] + if len(first) <= 160 and len(first.split()) >= 3: + # Prefer human-readable first heading over EPUB internal filename. + if "/" in fallback or fallback.lower().endswith(".xhtml"): + return first + return first + + if fallback and "/" not in fallback and not fallback.lower().endswith(".xhtml"): + return fallback + return f"Chương {number}" + + +def _extract_title_chapter_number(title: str) -> int | None: + normalized = _norm_title(title or "") + if not normalized: + return None + m = re.search(r"(?:chuong|ch\.?|chapter|hoi|quyen|phan|tap)\s*(\d+)", normalized, re.IGNORECASE) + if not m: + return None + try: + number = int(m.group(1)) + return number if number > 0 else None + except Exception: + return None + + +def _normalize_chapter_sequence(chapters: list[dict[str, Any]]) -> list[dict[str, Any]]: + if not chapters: + return [] + + normalized_items: list[dict[str, Any]] = [] + prev_number = 0 + for idx, ch in enumerate(chapters, start=1): + detected_number = _extract_title_chapter_number(str(ch.get("title") or "")) + if detected_number is None: + mapped_number = prev_number + 1 if prev_number > 0 else idx + else: + mapped_number = detected_number if detected_number > prev_number else (prev_number + 1) + + txt = str(ch.get("txt") or "").strip() + raw_html = str(ch.get("raw_html") or "").strip() + title = str(ch.get("title") or f"Chương {mapped_number}").strip() + + for missing in range(prev_number + 1, mapped_number): + normalized_items.append( + { + "number": missing, + "title": f"Chương {missing}", + "raw_html": "", + "txt": "", + "is_placeholder": True, + } + ) + + normalized_items.append( + { + "number": mapped_number, + "title": title, + "raw_html": raw_html, + "txt": txt, + "is_placeholder": False, + } + ) + prev_number = mapped_number + + return normalized_items + + def _extract_epub_chapters(epub_path: Path) -> list[dict[str, Any]]: from app.epub_parser import build_chapters_from_epub @@ -1217,18 +3070,505 @@ def _extract_epub_chapters(epub_path: Path) -> list[dict[str, Any]]: content = str(ch.get("content") or "") if not content.strip(): continue + txt = str(ch.get("txt") or "").strip() + title = _derive_chapter_title(txt, str(ch.get("title") or f"Chapter {idx}"), idx) chapters.append( { "number": int(ch.get("number") or idx), - "title": str(ch.get("title") or f"Chapter {idx}"), + "title": title, "raw_html": content, - "txt": str(ch.get("txt") or "").strip(), + "txt": txt, } ) return chapters -async def _resolve_chapter_content(chapter_id: str, mongo_fallback: str | None, db: AsyncSession) -> str | None: +def _is_toc_or_intro(chapter: dict[str, Any]) -> bool: + title = _norm_title(str(chapter.get("title") or "")) + txt = _norm_title(str(chapter.get("txt") or "")[:500]) + combined = f"{title} {txt}".strip() + if not combined: + return True + + if any(token in combined for token in ["muc luc", "table of contents", "contents", " nav xhtml", "toc"]): + return True + + title_intro_markers = ["gioi thieu", "mo dau", "loi mo dau", "tom tat", "description", "synopsis", "preface"] + if any(token in title for token in title_intro_markers): + return True + + chapter_like = re.search(r"\b(chuong|chapter|hoi|quyen|phan|tap|chuong\s*\d+|chapter\s*\d+)\b", combined) + if chapter_like: + return False + + intro_markers = ["gioi thieu", "mo dau", "loi mo dau", "tom tat", "mo ta", "description", "synopsis", "preface"] + if any(token in combined for token in intro_markers): + return True + + # Very short non-chapter sections are likely front/back matter. + if len(str(chapter.get("txt") or "").strip()) < 300: + return True + + return False + + +def _filter_toc_chapters(chapters: list[dict[str, Any]]) -> list[dict[str, Any]]: + if not chapters: + return [] + + filtered = [ch for ch in chapters if not _is_toc_or_intro(ch)] + if not filtered: + # fallback: only drop obvious TOC files to avoid empty result + filtered = [ + ch for ch in chapters + if "muc luc" not in _norm_title(str(ch.get("txt") or "")[:500]) + and "table of contents" not in _norm_title(str(ch.get("txt") or "")[:500]) + ] + + out: list[dict[str, Any]] = [] + for idx, ch in enumerate(filtered, start=1): + out.append({ + "number": idx, + "title": str(ch.get("title") or f"Chapter {idx}"), + "raw_html": str(ch.get("raw_html") or ""), + "txt": str(ch.get("txt") or ""), + }) + return out + + +def _extract_epub_chapters_by_regex(epub_path: Path, chapter_start_pattern: str) -> list[dict[str, Any]]: + chapters = _extract_epub_chapters(epub_path) + pattern = chapter_start_pattern.strip() + if not pattern: + return chapters + re_compiled = re.compile(pattern, re.IGNORECASE | re.MULTILINE) + + merged: list[dict[str, Any]] = [] + current: dict[str, Any] | None = None + for ch in chapters: + title = str(ch.get("title") or "") + txt = str(ch.get("txt") or "") + raw_html = str(ch.get("raw_html") or "") + starts = bool(re_compiled.search(title)) or bool(re_compiled.search(txt)) + if starts: + if current: + merged.append(current) + current = { + "number": len(merged) + 1, + "title": title or f"Chapter {len(merged) + 1}", + "txt": txt, + "raw_html": raw_html, + } + else: + if current is None: + # Ignore front/back matter before first real chapter match. + continue + current["txt"] = f"{current['txt']}\n\n{txt}".strip() + current["raw_html"] = f"{current['raw_html']}\n{raw_html}".strip() + if current: + merged.append(current) + return merged if merged else chapters + + +def _chapter_preview_samples(chapters: list[dict[str, Any]], sample_size: int = 10) -> list[dict[str, Any]]: + if not chapters: + return [] + + head = chapters[:sample_size] + if len(chapters) <= sample_size * 2: + middle = chapters[sample_size:] + tail = [] + else: + mid_start = max((len(chapters) // 2) - (sample_size // 2), sample_size) + middle = chapters[mid_start:mid_start + sample_size] + tail = chapters[-sample_size:] + + seen: set[int] = set() + out: list[dict[str, Any]] = [] + for group, label in [(head, "head"), (middle, "middle"), (tail, "tail")]: + for ch in group: + number = int(ch.get("number") or 0) + if number in seen: + continue + seen.add(number) + txt = str(ch.get("txt") or "") + out.append( + { + "bucket": label, + "number": number, + "title": str(ch.get("title") or ""), + "chars": len(txt), + "preview": txt[:280] if txt else ("(placeholder - no content)" if ch.get("is_placeholder") else ""), + "isPlaceholder": bool(ch.get("is_placeholder") or False), + } + ) + return out + + +def _epub_extract_with_mode(epub_path: Path, split_mode: str, chapter_start_pattern: str | None) -> list[dict[str, Any]]: + if split_mode == "regex": + default_vi_regex = r"^\s*(?:[#>*\-\[]\s*)*(?:ch(?:u\.?|ương|uong)?|chapter|hồi|hoi|quyển|quyen|phần|phan|tập|tap)\s*\d+(?:[\.:\-\)]\s*|\s+).+$" + effective_pattern = chapter_start_pattern or default_vi_regex + try: + return _normalize_chapter_sequence(_extract_epub_chapters_by_regex(epub_path, effective_pattern)) + except re.error as exc: + raise HTTPException(status_code=400, detail=f"Invalid chapterStartPattern: {exc}") from exc + return _normalize_chapter_sequence(_filter_toc_chapters(_extract_epub_chapters(epub_path))) + + +async def _ensure_genre_ids(db: AsyncSession, names: list[str]) -> list[str]: + out: list[str] = [] + for raw_name in names: + name = " ".join((raw_name or "").split()).strip() + if not name: + continue + slug = _norm_title(name).replace(" ", "-")[:120] or _new_id("genre_") + existing = ( + await db.execute( + text('SELECT id FROM "Genre" WHERE lower(name) = :name OR slug = :slug LIMIT 1'), + {"name": name.lower(), "slug": slug}, + ) + ).mappings().first() + if existing: + out.append(str(existing["id"])) + continue + gid = _new_id("genre_") + await db.execute( + text('INSERT INTO "Genre" (id, name, slug, description, icon) VALUES (:id, :name, :slug, NULL, NULL)'), + {"id": gid, "name": name, "slug": slug}, + ) + out.append(gid) + return out + + +def _ensure_genre_ids_sync(db: Any, names: list[str]) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for raw_name in names: + name = " ".join((raw_name or "").split()).strip() + if not name: + continue + slug = _norm_title(name).replace(" ", "-")[:120] or _new_id("genre_") + if slug in seen: + continue + seen.add(slug) + existing = db.execute( + text('SELECT id FROM "Genre" WHERE lower(name) = :name OR slug = :slug LIMIT 1'), + {"name": name.lower(), "slug": slug}, + ).mappings().first() + if existing: + out.append(str(existing["id"])) + continue + gid = _new_id("genre_") + db.execute( + text('INSERT INTO "Genre" (id, name, slug, description, icon) VALUES (:id, :name, :slug, NULL, NULL)'), + {"id": gid, "name": name, "slug": slug}, + ) + out.append(gid) + return out + + +def _build_ai_genre_suggestions(chapters: list[dict[str, Any]]) -> list[str]: + hay = " ".join([str(ch.get("title") or "") + " " + str(ch.get("txt") or "")[:800] for ch in chapters[:8]]).lower() + mapping = [ + ("tiên hiệp", ["tu tiên", "linh khí", "đan điền", "nguyên anh"]), + ("kiếm hiệp", ["kiếm", "giang hồ", "môn phái", "võ công"]), + ("đô thị", ["thành phố", "công ty", "tổng tài", "đô thị"]), + ("hệ thống", ["hệ thống", "nhiệm vụ", "kỹ năng", "điểm thưởng"]), + ("huyền huyễn", ["ma pháp", "huyền", "long", "thần"]), + ("xuyên không", ["xuyên", "trùng sinh", "trở về", "quá khứ"]), + ("ngôn tình", ["tình yêu", "hôn", "nam chính", "nữ chính"]), + ("trinh thám", ["vụ án", "hung thủ", "điều tra", "manh mối"]), + ] + picked: list[str] = [] + for genre, keys in mapping: + if any(k in hay for k in keys): + picked.append(genre) + if len(picked) >= 6: + break + if not picked: + picked = ["tiểu thuyết"] + return picked[:6] + + +def _build_ai_description(title: str, author: str | None, chapters: list[dict[str, Any]]) -> str: + first = (str(chapters[0].get("txt") or "")[:180] if chapters else "").strip() + author_text = author or "Tác giả chưa rõ" + if first: + return f"{title} của {author_text} mở ra câu chuyện với nhịp đọc cuốn hút, tập trung vào hành trình nhân vật chính và các bước ngoặt liên tiếp. Bối cảnh được triển khai rõ nét, phù hợp cho độc giả thích theo dõi mạch truyện dài hơi." + return f"{title} là tác phẩm của {author_text}, có nhịp truyện rõ ràng và dễ theo dõi theo từng chương. Nội dung phù hợp để đọc liên tục với mạch phát triển ổn định." + + +def _extract_epub_cover(epub_path: Path) -> tuple[bytes, str] | None: + from ebooklib import ITEM_COVER, ITEM_IMAGE + from ebooklib import epub as epublib + + try: + book = epublib.read_epub(str(epub_path), options={"ignore_ncx": False}) + except Exception: + return None + + for item in book.get_items(): + try: + media_type = str(getattr(item, "media_type", "") or "") + name = str(getattr(item, "file_name", "") or getattr(item, "get_name", lambda: "")() or "").lower() + item_type = item.get_type() if hasattr(item, "get_type") else None + + is_image = media_type.startswith("image/") or item_type == ITEM_IMAGE + is_cover = item_type == ITEM_COVER or "cover" in name + if not is_image: + continue + + data = item.get_content() if hasattr(item, "get_content") else b"" + if not data: + continue + + # Prefer explicit cover first, otherwise fallback to first image. + if is_cover or not name: + ext = ".jpg" + if media_type == "image/png": + ext = ".png" + elif media_type == "image/webp": + ext = ".webp" + return data, ext + except Exception: + continue + + # Fallback: first image in book package. + for item in book.get_items(): + try: + media_type = str(getattr(item, "media_type", "") or "") + if not media_type.startswith("image/"): + continue + data = item.get_content() if hasattr(item, "get_content") else b"" + if not data: + continue + ext = ".jpg" + if media_type == "image/png": + ext = ".png" + elif media_type == "image/webp": + ext = ".webp" + return data, ext + except Exception: + continue + return None + + +def _upload_cover_bytes_to_r2(image_bytes: bytes, extension: str, *, key_prefix: str) -> str | None: + if not image_bytes: + return None + if ( + not settings.r2_account_id + or not settings.r2_access_key_id + or not settings.r2_secret_access_key + or not settings.r2_bucket_name + ): + return None + + try: + s3 = 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", + ) + key = f"covers/{key_prefix}-{int(time.time() * 1000)}{extension}" + content_type = "image/jpeg" + if extension == ".png": + content_type = "image/png" + elif extension == ".webp": + content_type = "image/webp" + + s3.put_object( + Bucket=settings.r2_bucket_name, + Key=key, + Body=image_bytes, + ContentType=content_type, + CacheControl="public, max-age=31536000, immutable", + ) + + base = (settings.r2_public_base_url or "").rstrip("/") + if base: + return f"{base}/{key}" + return key + except Exception: + return None + + +def _upload_cover_to_r2(image_bytes: bytes, extension: str, *, source_asset_id: str) -> str | None: + return _upload_cover_bytes_to_r2( + image_bytes, + extension, + key_prefix=f"import-cover-{source_asset_id}", + ) + + +def _r2_key_from_cover_url(cover_url: str | None) -> str | None: + raw = str(cover_url or "").strip() + if not raw: + return None + if raw.startswith("covers/"): + return raw + base = (settings.r2_public_base_url or "").rstrip("/") + if base and raw.startswith(base + "/"): + key = raw[len(base) + 1 :] + return key or None + return None + + +def _delete_r2_key(key: str | None) -> bool: + target = str(key or "").strip() + if not target: + return False + if ( + not settings.r2_account_id + or not settings.r2_access_key_id + or not settings.r2_secret_access_key + or not settings.r2_bucket_name + ): + return False + try: + s3 = 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", + ) + s3.delete_object(Bucket=settings.r2_bucket_name, Key=target) + return True + except Exception: + return False + + +def _map_genres_to_existing(candidates: list[str], existing_genres: list[str], *, limit: int = 6) -> list[str]: + existing_clean = [g.strip() for g in existing_genres if g and g.strip()] + existing_norm = [(_norm_title(g), g) for g in existing_clean] + + output: list[str] = [] + used_norm: set[str] = set() + for raw in candidates: + name = (raw or "").strip() + if not name: + continue + cand_norm = _norm_title(name) + if not cand_norm: + continue + + best_name = name + best_score = 0.0 + for ex_norm, ex_name in existing_norm: + if cand_norm == ex_norm: + best_name = ex_name + best_score = 1.0 + break + score = SequenceMatcher(None, cand_norm, ex_norm).ratio() + if score > best_score: + best_score = score + best_name = ex_name + + # Snap to existing genre when similarity is strong. + final_name = best_name if best_score >= 0.86 else name + final_norm = _norm_title(final_name) + if final_norm in used_norm: + continue + used_norm.add(final_norm) + output.append(final_name) + if len(output) >= limit: + break + + return output + + +async def _deepseek_ai_suggest( + title: str, + author: str, + chapters: list[dict[str, Any]], + existing_genres: list[str], +) -> dict[str, Any] | None: + api_key = (settings.deepseek_key or "").strip() + if not api_key: + return None + + samples: list[str] = [] + if chapters: + picks = [chapters[0]] + if len(chapters) > 2: + picks.append(chapters[len(chapters) // 2]) + if len(chapters) > 1: + picks.append(chapters[-1]) + for ch in picks: + snippet = str(ch.get("txt") or "")[:1200] + samples.append(f"Chapter {ch.get('number')}: {ch.get('title')}\n{snippet}") + + system_prompt = ( + "You are a Vietnamese fiction metadata assistant. " + "Return strict JSON with keys: genres, shortDescription, confidence. " + "genres must be array of 1-6 concise genre strings. " + "Prioritize selecting from existingGenres first; only create new genres when truly needed. " + "shortDescription must be 2-4 Vietnamese sentences. " + "confidence is number 0..1." + ) + user_prompt = { + "title": title, + "author": author, + "chapterSamples": samples, + "existingGenres": existing_genres, + "requirements": { + "maxGenres": 6, + "allowNewGenres": True, + "preferExistingGenres": True, + "language": "vi", + }, + } + + payload = { + "model": settings.deepseek_model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": json.dumps(user_prompt, ensure_ascii=False)}, + ], + "temperature": 0.3, + "max_tokens": 500, + "response_format": {"type": "json_object"}, + } + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + "https://api.deepseek.com/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json=payload, + ) + response.raise_for_status() + data = response.json() + content = ( + data.get("choices", [{}])[0] + .get("message", {}) + .get("content", "") + ) + parsed = json.loads(content) if isinstance(content, str) else {} + raw_genres = [str(g).strip() for g in (parsed.get("genres") or []) if str(g).strip()][:6] + genres = _map_genres_to_existing(raw_genres, existing_genres, limit=6) + short_description = str(parsed.get("shortDescription") or "").strip() + try: + confidence = float(parsed.get("confidence") or 0.0) + except Exception: + confidence = 0.0 + confidence = max(0.0, min(1.0, confidence)) + if not short_description or not genres: + return None + return { + "suggestedGenres": genres, + "shortDescription": short_description, + "confidence": confidence, + } + except Exception: + return None + + +async def _resolve_chapter_content(chapter_id: str, db: AsyncSession) -> str | None: ref_row = ( await db.execute( text('SELECT "txtHref" FROM "ChapterContentRef" WHERE "chapterId" = :chapter_id LIMIT 1'), @@ -1236,22 +3576,12 @@ async def _resolve_chapter_content(chapter_id: str, mongo_fallback: str | None, ) ).mappings().first() - if settings.chapter_content_mode == "mongo_first": - if mongo_fallback: - return mongo_fallback - if ref_row: - try: - return storage.read_text(ref_row["txtHref"]) - except Exception: - return mongo_fallback - return mongo_fallback - if ref_row: try: return storage.read_text(ref_row["txtHref"]) except Exception: - return mongo_fallback - return mongo_fallback + return None + return None @app.get("/api/import/assets") @@ -1293,7 +3623,686 @@ async def list_source_assets( {**params, "offset": offset}, ) ).mappings().all() - return [dict(r) for r in rows] + novels = (await db.execute(text('SELECT id, title FROM "Novel"'))).mappings().all() + out: list[dict[str, Any]] = [] + normalized_query = _norm_title(q or "") + query_tokens = [t for t in normalized_query.split(" ") if t] + for r in rows: + item = dict(r) + base = (item.get("path") or "").split("/")[-1].rsplit(".", 1)[0] + normalized_path = _norm_title(str(item.get("path") or "")) + if query_tokens and not all(tok in normalized_path for tok in query_tokens): + continue + best = {"id": None, "score": 0.0} + for n in novels: + sc = _title_score(base, str(n.get("title") or "")) + if sc > best["score"]: + best = {"id": n.get("id"), "score": sc} + item["matchedNovelId"] = best["id"] + item["matchScore"] = round(best["score"], 4) + item["converted"] = best["score"] >= 0.9 + if unconvertedOnly and item["converted"]: + continue + out.append(item) + return out + + +@app.get("/api/import/assets/search") +async def search_source_assets( + q: str, + page: int = Query(default=1, ge=1), + limit: int = Query(default=20, ge=1, le=100), + status: str | None = None, + db: AsyncSession = Depends(get_db_session), +): + query = _normalized_search_name(q) + if len(query) < 2: + return {"items": [], "pagination": {"page": page, "limit": limit, "total": 0, "totalPages": 0}} + + where = ['search_name IS NOT NULL'] + params: dict[str, Any] = { + "q_prefix": f"{query}%", + "q_like": f"%{query}%", + "offset": (page - 1) * limit, + "limit": limit, + } + if status: + where.append('status = :status') + params["status"] = status + + where_sql = " AND ".join(where) + total = ( + await db.execute( + text(f'SELECT COUNT(*)::int FROM "SourceAsset" WHERE {where_sql} AND (search_name LIKE :q_prefix OR search_name ILIKE :q_like)'), + params, + ) + ).scalar_one() + rows = ( + await db.execute( + text( + f'SELECT id, path, title, author, status, "updatedAt" ' + f'FROM "SourceAsset" ' + f'WHERE {where_sql} AND (search_name LIKE :q_prefix OR search_name ILIKE :q_like) ' + f'ORDER BY CASE WHEN search_name LIKE :q_prefix THEN 0 ELSE 1 END, "updatedAt" DESC ' + f'OFFSET :offset LIMIT :limit' + ), + params, + ) + ).mappings().all() + + total_pages = max((total + limit - 1) // limit, 1) if total else 0 + return { + "items": [dict(row) for row in rows], + "pagination": {"page": page, "limit": limit, "total": int(total), "totalPages": total_pages}, + } + + +@app.get("/api/import/assets/{asset_id}/preview-metadata") +async def preview_source_asset_metadata( + 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( + 'SELECT id, path, title, author, status, review_status, review_payload, sha256, "updatedAt" ' + 'FROM "SourceAsset" WHERE id = :id LIMIT 1' + ), + {"id": asset_id}, + ) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Source asset not found") + + path = str(row["path"]) + base = path.split("/")[-1].rsplit(".", 1)[0] + source_path = Path(settings.epub_source_root) / path + cover_detected = bool(_extract_epub_cover(source_path)) if source_path.exists() else False + return { + "asset": {**dict(row), "coverDetected": cover_detected}, + "suggested": { + "title": row.get("title") or base, + "author": row.get("author") or "Unknown", + "shortDescription": None, + "genres": [], + }, + } + + +@app.get("/api/import/assets/{asset_id}/preview-cover") +async def preview_source_asset_cover( + 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('SELECT id, path FROM "SourceAsset" WHERE id = :id LIMIT 1'), {"id": asset_id}) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Source asset not found") + + source_path = Path(settings.epub_source_root) / str(row["path"]) + if not source_path.exists(): + raise HTTPException(status_code=400, detail="EPUB source file not found") + cover = _extract_epub_cover(source_path) + if not cover: + raise HTTPException(status_code=404, detail="Cover not found in EPUB") + cover_bytes, ext = cover + media_type = "image/jpeg" + if ext == ".png": + media_type = "image/png" + elif ext == ".webp": + media_type = "image/webp" + return Response(content=cover_bytes, media_type=media_type) + + +@app.post("/api/import/assets/{asset_id}/upload-cover") +async def upload_source_asset_cover( + asset_id: str, + file: UploadFile = File(...), + 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('SELECT id, review_payload FROM "SourceAsset" WHERE id = :id LIMIT 1'), {"id": asset_id}) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Source asset not found") + + content = await file.read() + if not content: + raise HTTPException(status_code=400, detail="File cover rong") + ext = ".jpg" + ct = (file.content_type or "").lower() + if "png" in ct: + ext = ".png" + elif "webp" in ct: + ext = ".webp" + elif "jpeg" in ct or "jpg" in ct: + ext = ".jpg" + + cover_url = _upload_cover_bytes_to_r2(content, ext, key_prefix=f"manual-cover-{asset_id}") + if not cover_url: + raise HTTPException(status_code=500, detail="Upload cover that bai") + + review_payload = row.get("review_payload") or {} + if isinstance(review_payload, str): + try: + review_payload = json.loads(review_payload) + except Exception: + review_payload = {} + review_payload["manualCoverUrl"] = cover_url + + await db.execute( + text('UPDATE "SourceAsset" SET review_payload = CAST(:review_payload AS jsonb), "updatedAt" = NOW() WHERE id = :id'), + {"id": asset_id, "review_payload": json.dumps(review_payload)}, + ) + await db.commit() + + return {"assetId": asset_id, "coverUrl": cover_url, "uploaded": True} + + +@app.post("/api/import/assets/{asset_id}/review") +async def review_source_asset( + asset_id: str, + payload: SourceAssetReviewPayload, + 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") + + if payload.targetMode == "existing" and not payload.novelId: + raise HTTPException(status_code=400, detail="novelId is required when targetMode=existing") + + row = ( + await db.execute( + text( + 'UPDATE "SourceAsset" SET title = COALESCE(:title, title), author = COALESCE(:author, author), ' + 'review_status = :review_status, review_payload = CAST(:review_payload AS jsonb), status = :status, "updatedAt" = NOW() ' + 'WHERE id = :id RETURNING id, path, title, author, status, review_status, review_payload, "updatedAt"' + ), + { + "id": asset_id, + "title": payload.title, + "author": payload.author, + "review_status": "reviewed", + "review_payload": json.dumps(payload.model_dump()), + "status": "approved", + }, + ) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Source asset not found") + await db.commit() + return dict(row) + + +@app.post("/api/import/assets/{asset_id}/ai-suggest") +async def ai_suggest_source_asset( + asset_id: str, + payload: SourceAssetAiSuggestPayload, + 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('SELECT id, path, title, author FROM "SourceAsset" WHERE id = :id LIMIT 1'), {"id": asset_id}) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Source asset not found") + + source_path = Path(settings.epub_source_root) / str(row["path"]) + if not source_path.exists(): + raise HTTPException(status_code=400, detail="EPUB source file not found") + + chapters = _epub_extract_with_mode(source_path, payload.splitMode, payload.chapterStartPattern) + title = str(row.get("title") or source_path.stem) + author = str(row.get("author") or "Unknown") + existing_genres = [ + str(r.get("name") or "") + for r in (await db.execute(text('SELECT name FROM "Genre" ORDER BY name ASC'))).mappings().all() + if str(r.get("name") or "").strip() + ] + + ai_result = await _deepseek_ai_suggest(title, author, chapters, existing_genres) + if ai_result: + return { + "assetId": asset_id, + "suggestedGenres": ai_result["suggestedGenres"][:6], + "shortDescription": ai_result["shortDescription"], + "confidence": ai_result["confidence"], + "source": "deepseek", + "existingGenresCount": len(existing_genres), + } + + genres = _build_ai_genre_suggestions(chapters) + genres = _map_genres_to_existing(genres, existing_genres, limit=6) + description = _build_ai_description(title, author, chapters) + return { + "assetId": asset_id, + "suggestedGenres": genres[:6], + "shortDescription": description, + "confidence": 0.62, + "source": "rule_based_fallback", + "existingGenresCount": len(existing_genres), + } + + +@app.post("/api/import/assets/{asset_id}/parse-preview") +async def parse_preview_source_asset( + asset_id: str, + payload: SourceAssetParsePreviewPayload, + 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('SELECT id, path FROM "SourceAsset" WHERE id = :id LIMIT 1'), {"id": asset_id}) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Source asset not found") + source_path = Path(settings.epub_source_root) / str(row["path"]) + if not source_path.exists(): + raise HTTPException(status_code=400, detail="EPUB source file not found") + + chapters = _epub_extract_with_mode(source_path, payload.splitMode, payload.chapterStartPattern) + return { + "assetId": asset_id, + "splitMode": payload.splitMode, + "chapterCount": len(chapters), + "sample": _chapter_preview_samples(chapters, sample_size=10), + "warnings": [] if len(chapters) >= 3 else ["chapter_count_too_low"], + } + + +def _run_import_session_task(session_id: str) -> None: + from app.database import SessionLocal + + async def _run() -> None: + db = SessionLocal() + try: + row = ( + await db.execute( + text( + 'SELECT s.id, s."sourceAssetId", s."novelId", s.status, a.path, a.review_payload, a.title, a.author ' + 'FROM "ImportSession" s JOIN "SourceAsset" a ON a.id = s."sourceAssetId" WHERE s.id = :id LIMIT 1' + ), + {"id": session_id}, + ) + ).mappings().first() + if not row: + return + + await db.execute( + text('UPDATE "ImportSession" SET status = :st, phase = :ph, "progressPct" = :pct, "updatedAt" = NOW() WHERE id = :id'), + {"id": session_id, "st": "processing", "ph": "prepare", "pct": 5.0}, + ) + await db.commit() + + source_path = Path(settings.epub_source_root) / str(row["path"]) + if not source_path.exists(): + await db.execute( + text('UPDATE "ImportSession" SET status = :st, phase = :ph, log = :log, "updatedAt" = NOW() WHERE id = :id'), + {"id": session_id, "st": "failed", "ph": "prepare", "log": "EPUB source file not found"}, + ) + await db.commit() + return + + review_payload = row.get("review_payload") or {} + if isinstance(review_payload, str): + try: + review_payload = json.loads(review_payload) + except Exception: + review_payload = {} + + cover_url: str | None = str(review_payload.get("manualCoverUrl") or "").strip() or None + if not cover_url: + cover_extracted = _extract_epub_cover(source_path) + if cover_extracted: + cover_bytes, cover_ext = cover_extracted + cover_url = _upload_cover_to_r2(cover_bytes, cover_ext, source_asset_id=str(row["sourceAssetId"])) + + split_mode = str(review_payload.get("splitMode") or "toc") + chapter_start_pattern = review_payload.get("chapterStartPattern") + target_mode = str(review_payload.get("targetMode") or "new") + replace_existing = bool(review_payload.get("replaceExisting") or False) + + await db.execute( + text('UPDATE "ImportSession" SET phase = :ph, "progressPct" = :pct, "updatedAt" = NOW() WHERE id = :id'), + {"id": session_id, "ph": "parse", "pct": 20.0}, + ) + await db.commit() + await db.execute( + text('UPDATE "ImportSession" SET log = :log, "updatedAt" = NOW() WHERE id = :id'), + {"id": session_id, "log": "parsing epub"}, + ) + await db.commit() + chapters = await asyncio.wait_for( + asyncio.to_thread(_epub_extract_with_mode, source_path, split_mode, chapter_start_pattern), + timeout=180, + ) + + novel_id = row.get("novelId") + if not novel_id and target_mode == "existing": + novel_id = review_payload.get("novelId") + if novel_id: + novel_exists = (await db.execute(text('SELECT id FROM "Novel" WHERE id = :id LIMIT 1'), {"id": novel_id})).mappings().first() + if not novel_exists: + raise RuntimeError("Target novel not found") + if not novel_id: + base_title = str(review_payload.get("title") or row.get("title") or source_path.stem) + slug = _norm_title(base_title).replace(" ", "-")[:120] or _new_id("n_") + existing_slug_row = ( + await db.execute(text('SELECT id FROM "Novel" WHERE slug = :slug LIMIT 1'), {"slug": slug}) + ).mappings().first() + if existing_slug_row: + slug = f"{slug}-{_new_id()[:8]}" + novel_id = _new_id("n_") + await db.execute( + text('INSERT INTO "Novel" (id, title, slug, "authorName", description, "coverUrl", status, "totalChapters", views, rating, "ratingCount", "bookmarkCount", "createdAt", "updatedAt") VALUES (:id,:title,:slug,:author,:desc,:cover_url,:status,0,0,0,0,0,NOW(),NOW())'), + { + "id": novel_id, + "title": base_title, + "slug": slug, + "author": str(review_payload.get("author") or row.get("author") or "Unknown"), + "desc": str(review_payload.get("shortDescription") or ""), + "cover_url": cover_url, + "status": "Đang ra", + }, + ) + elif cover_url: + await db.execute( + text('UPDATE "Novel" SET "coverUrl" = COALESCE("coverUrl", :cover_url), "updatedAt" = NOW() WHERE id = :id'), + {"id": novel_id, "cover_url": cover_url}, + ) + + genres = [str(g) for g in (review_payload.get("genres") or [])] + if genres: + genre_ids = await _ensure_genre_ids(db, genres) + for gid in genre_ids: + await db.execute( + text('INSERT INTO "NovelGenre" ("novelId", "genreId") VALUES (:novel_id, :genre_id) ON CONFLICT DO NOTHING'), + {"novel_id": novel_id, "genre_id": gid}, + ) + + await db.execute( + text('UPDATE "ImportSession" SET phase = :ph, "progressPct" = :pct, "novelId" = :novel_id, "updatedAt" = NOW() WHERE id = :id'), + {"id": session_id, "ph": "write_nas", "pct": 50.0, "novel_id": novel_id}, + ) + await db.commit() + + added = 0 + replaced = 0 + skipped = 0 + failed = 0 + last_error: str | None = None + asset_id = str(row["sourceAssetId"]) + total_chapters = max(1, len(chapters)) + await db.execute( + text('UPDATE "ImportSession" SET phase = :ph, log = :log, "updatedAt" = NOW() WHERE id = :id'), + {"id": session_id, "ph": "write_nas", "log": f"writing chapters 0/{total_chapters}"}, + ) + await db.commit() + + async def _write_storage_text(href: str, content: str) -> None: + await asyncio.wait_for(asyncio.to_thread(storage.write_text, href, content), timeout=20) + + for idx, ch in enumerate(chapters, start=1): + processed_this = False + try: + async with db.begin_nested(): + num = int(ch.get("number") or 0) + if num <= 0: + failed += 1 + continue + is_placeholder = bool(ch.get("is_placeholder") or False) + existing = ( + await db.execute( + text('SELECT id FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number = :num LIMIT 1'), + {"novel_id": novel_id, "num": num}, + ) + ).mappings().first() + + if existing: + if replace_existing: + await db.execute(text('UPDATE "ChapterMeta" SET title = :title WHERE id = :id'), {"id": existing["id"], "title": str(ch.get("title") or f"Chapter {num}")}) + if not is_placeholder: + txt_href = f"{asset_id}/{num}.txt" + raw_href = f"{asset_id}/{num}.raw.html" + txt = str(ch.get("txt") or "") + await _write_storage_text(txt_href, txt) + await _write_storage_text(raw_href, str(ch.get("raw_html") or "")) + h = hashlib.sha256(txt.encode("utf-8")).hexdigest() + await db.execute(text('INSERT INTO "ChapterContentRef" ("chapterId","txtHref","rawHtmlHref","contentHash") VALUES (:id,:txt,:raw,:hash) ON CONFLICT ("chapterId") DO UPDATE SET "txtHref"=EXCLUDED."txtHref", "rawHtmlHref"=EXCLUDED."rawHtmlHref", "contentHash"=EXCLUDED."contentHash", "updatedAt"=NOW()'), {"id": existing["id"], "txt": txt_href, "raw": raw_href, "hash": h}) + else: + await db.execute(text('DELETE FROM "ChapterContentRef" WHERE "chapterId" = :id'), {"id": existing["id"]}) + replaced += 1 + processed_this = True + else: + skipped += 1 + processed_this = True + continue + + cid = _new_id("cmeta_") + await db.execute(text('INSERT INTO "ChapterMeta" (id, "novelId", number, title, views, "createdAt") VALUES (:id,:novel,:num,:title,0,NOW())'), {"id": cid, "novel": novel_id, "num": num, "title": str(ch.get("title") or f"Chapter {num}")}) + if not is_placeholder: + txt_href = f"{asset_id}/{num}.txt" + raw_href = f"{asset_id}/{num}.raw.html" + txt = str(ch.get("txt") or "") + await _write_storage_text(txt_href, txt) + await _write_storage_text(raw_href, str(ch.get("raw_html") or "")) + h = hashlib.sha256(txt.encode("utf-8")).hexdigest() + await db.execute(text('INSERT INTO "ChapterContentRef" ("chapterId","txtHref","rawHtmlHref","contentHash") VALUES (:id,:txt,:raw,:hash)'), {"id": cid, "txt": txt_href, "raw": raw_href, "hash": h}) + added += 1 + processed_this = True + except Exception as exc: + failed += 1 + processed_this = True + if last_error is None: + last_error = str(exc) + + if not processed_this: + skipped += 1 + processed_this = True + + if idx % 10 == 0 or idx == total_chapters: + progress = 50.0 + (float(idx) / float(total_chapters)) * 45.0 + processed = added + replaced + skipped + failed + await db.execute( + text('UPDATE "ImportSession" SET "progressPct" = :pct, log = :log, "updatedAt" = NOW() WHERE id = :id'), + {"id": session_id, "pct": min(progress, 95.0), "log": f"writing chapters {processed}/{total_chapters}"}, + ) + await db.commit() + + await db.execute(text('UPDATE "Novel" SET description = COALESCE(:desc, description), "totalChapters" = (SELECT COUNT(*) FROM "ChapterMeta" WHERE "novelId" = :novel_id), "updatedAt" = NOW() WHERE id = :novel_id'), {"novel_id": novel_id, "desc": review_payload.get("shortDescription")}) + await db.execute( + text('UPDATE "ImportSession" SET status = :st, phase = :ph, "progressPct" = :pct, "resultJson" = CAST(:result AS jsonb), "updatedAt" = NOW() WHERE id = :id'), + { + "id": session_id, + "st": "completed", + "ph": "finalize", + "pct": 100.0, + "result": json.dumps({"parsed": len(chapters), "added": added, "replaced": replaced, "skipped": skipped, "failed": failed, "novelId": novel_id, "lastError": last_error}), + }, + ) + await db.execute( + text('UPDATE "SourceAsset" SET status = :status, review_status = :review_status, "updatedAt" = NOW() WHERE id = :id'), + {"id": row["sourceAssetId"], "status": "completed" if failed == 0 else "review_required", "review_status": "imported" if failed == 0 else "reviewed"}, + ) + await db.commit() + except Exception as exc: + try: + await db.rollback() + await db.execute( + text('UPDATE "ImportSession" SET status = :st, log = :log, "updatedAt" = NOW() WHERE id = :id'), + {"id": session_id, "st": "failed", "log": str(exc)}, + ) + await db.commit() + except Exception: + pass + finally: + await db.close() + + return _run() + + +@app.post("/api/import/assets/{asset_id}/start-import") +async def start_import_source_asset( + asset_id: str, + payload: SourceAssetStartImportPayload, + 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") + asset = ( + await db.execute(text('SELECT id, status, review_payload FROM "SourceAsset" WHERE id = :id LIMIT 1'), {"id": asset_id}) + ).mappings().first() + if not asset: + raise HTTPException(status_code=404, detail="Source asset not found") + + review_payload = asset.get("review_payload") or {} + if isinstance(review_payload, str): + try: + review_payload = json.loads(review_payload) + except Exception: + review_payload = {} + review_payload["replaceExisting"] = bool(payload.replaceExisting) + review_payload["splitMode"] = payload.splitMode + review_payload["chapterStartPattern"] = payload.chapterStartPattern + + await db.execute( + text('UPDATE "SourceAsset" SET review_payload = CAST(:review_payload AS jsonb), "updatedAt" = NOW() WHERE id = :id'), + {"id": asset_id, "review_payload": json.dumps(review_payload)}, + ) + + session_id = _new_id("is_") + await db.execute( + text( + 'INSERT INTO "ImportSession" (id, "sourceAssetId", "novelId", status, phase, "progressPct", log, "resultJson", "createdBy") ' + 'VALUES (:id, :asset, :novel, :status, :phase, :pct, :log, :result, :created_by)' + ), + { + "id": session_id, + "asset": asset_id, + "novel": payload.forceNovelId, + "status": "pending", + "phase": "prepare", + "pct": 0.0, + "log": None, + "result": None, + "created_by": str(user.get("id") or ""), + }, + ) + await db.commit() + + task = asyncio.create_task(_run_import_session_task(session_id), name=f"import-session-{session_id}") + _IMPORT_TASKS.add(task) + task.add_done_callback(lambda t: _IMPORT_TASKS.discard(t)) + return {"sessionId": session_id, "status": "pending", "phase": "prepare", "progressPct": 0} + + +@app.get("/api/import/sessions/{session_id}") +async def get_import_session( + session_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( + 'SELECT id, "sourceAssetId", "novelId", status, phase, "progressPct", log, "resultJson", "createdAt", "updatedAt" ' + 'FROM "ImportSession" WHERE id = :id LIMIT 1' + ), + {"id": session_id}, + ) + ).mappings().first() + if not row: + raise HTTPException(status_code=404, detail="Import session not found") + out = dict(row) + result = out.get("resultJson") + if isinstance(result, str): + try: + out["resultJson"] = json.loads(result) + except Exception: + out["resultJson"] = None + return out + + +class ConvertAssetPayload(BaseModel): + assetId: str + + +@app.post("/api/import/convert") +async def convert_asset( + payload: ConvertAssetPayload, + 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") + asset = ( + await db.execute(text('SELECT id, path FROM "SourceAsset" WHERE id = :id LIMIT 1'), {"id": payload.assetId}) + ).mappings().first() + if not asset: + raise HTTPException(status_code=404, detail="Source asset not found") + + base = str(asset.get("path") or "").split("/")[-1].rsplit(".", 1)[0] + novels = (await db.execute(text('SELECT id, title FROM "Novel"'))).mappings().all() + best = {"id": None, "score": 0.0} + for n in novels: + sc = _title_score(base, str(n.get("title") or "")) + if sc > best["score"]: + best = {"id": n.get("id"), "score": sc} + novel_id = best["id"] + if not novel_id: + novel_id = _new_id("n_") + slug = _norm_title(base).replace(" ", "-")[:120] or novel_id + await db.execute( + text('INSERT INTO "Novel" (id, title, slug, "authorName", description, status, "totalChapters", views, rating, "ratingCount", "bookmarkCount", "createdAt", "updatedAt") VALUES (:id,:title,:slug,:author,:desc,:status,0,0,0,0,0,NOW(),NOW())'), + {"id": novel_id, "title": base, "slug": slug, "author": "Unknown", "desc": "", "status": "Đang ra"}, + ) + + source_path = Path(settings.epub_source_root) / str(asset["path"]) + if not source_path.exists(): + raise HTTPException(status_code=400, detail="EPUB source file not found") + chapters = _extract_epub_chapters(source_path) + added = 0 + for ch in chapters: + num = int(ch.get("number") or 0) + if num <= 0: + continue + existing = (await db.execute(text('SELECT id FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number = :num LIMIT 1'), {"novel_id": novel_id, "num": num})).mappings().first() + if existing: + continue + cid = _new_id("c_") + txt_href = f"{payload.assetId}/{num}.txt" + raw_href = f"{payload.assetId}/{num}.raw.html" + txt = str(ch.get("txt") or "") + storage.write_text(txt_href, txt) + storage.write_text(raw_href, str(ch.get("raw_html") or "")) + h = hashlib.sha256(txt.encode("utf-8")).hexdigest() + await db.execute(text('INSERT INTO "ChapterMeta" (id, "novelId", number, title, views, "createdAt") VALUES (:id,:novel,:num,:title,0,NOW())'), {"id": cid, "novel": novel_id, "num": num, "title": str(ch.get("title") or f"Chapter {num}")}) + await db.execute(text('INSERT INTO "ChapterContentRef" ("chapterId","txtHref","rawHtmlHref","contentHash") VALUES (:id,:txt,:raw,:hash)'), {"id": cid, "txt": txt_href, "raw": raw_href, "hash": h}) + added += 1 + await db.execute(text('UPDATE "Novel" SET "totalChapters" = (SELECT COUNT(*) FROM "ChapterMeta" WHERE "novelId" = :novel_id), "updatedAt" = NOW() WHERE id = :novel_id'), {"novel_id": novel_id}) + await db.commit() + return {"assetId": payload.assetId, "novelId": novel_id, "added": added, "done": True} @app.post("/api/import/assets/auto-review") @@ -1411,39 +4420,7 @@ async def discover_epub_assets( if user.get("role") not in ("MOD", "ADMIN"): raise HTTPException(status_code=403, detail="Forbidden") - root = Path(settings.epub_source_root) - if not root.exists(): - raise HTTPException(status_code=400, detail=f"EPUB source root not found: {settings.epub_source_root}") - - found = sorted(root.rglob("*.epub"))[:limit] - discovered = 0 - updated = 0 - - for epub_path in found: - sha256 = _asset_file_sha256(epub_path) - rel_path = str(epub_path.relative_to(root)) - existing = ( - await db.execute(text('SELECT id FROM "SourceAsset" WHERE sha256 = :sha LIMIT 1'), {"sha": sha256}) - ).mappings().first() - - if existing: - await db.execute( - text('UPDATE "SourceAsset" SET path = :path, "updatedAt" = NOW() WHERE id = :id'), - {"id": existing["id"], "path": rel_path}, - ) - updated += 1 - continue - - await db.execute( - text( - 'INSERT INTO "SourceAsset" (id, path, sha256, status) VALUES (:id, :path, :sha, :status)' - ), - {"id": _new_id("asset_"), "path": rel_path, "sha": sha256, "status": "discovered"}, - ) - discovered += 1 - - await db.commit() - return {"scanned": len(found), "discovered": discovered, "updated": updated} + return await _discover_assets_incremental(limit=limit) @app.post("/api/import/assets/{asset_id}/approve") @@ -1642,6 +4619,98 @@ async def run_import_job( raise HTTPException(status_code=500, detail="Import job failed") from exc +@app.post("/api/import/jobs/{job_id}/preview") +async def preview_import_job( + job_id: str, + payload: ImportApplyPayload, + 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") + + job = (await db.execute(text('SELECT id, "sourceAssetId" FROM "ImportJob" WHERE id = :id LIMIT 1'), {"id": job_id})).mappings().first() + if not job: + raise HTTPException(status_code=404, detail="Import job not found") + asset_id = str(job["sourceAssetId"]) + asset_dir = Path(settings.nas_content_root) / asset_id + if not asset_dir.exists(): + raise HTTPException(status_code=400, detail="Converted content folder not found") + + await db.execute(text('DELETE FROM "ImportCandidateChapter" WHERE "jobId" = :job_id'), {"job_id": job_id}) + created = 0 + for txt_file in sorted(asset_dir.glob("*.txt")): + token = txt_file.stem + if not token.isdigit(): + continue + num = int(token) + title = txt_file.with_suffix(".raw.html").name + chapter = (await db.execute(text('SELECT id FROM "ChapterMeta" WHERE "novelId" = :novel_id AND number = :num LIMIT 1'), {"novel_id": payload.novelId, "num": num})).mappings().first() + action = "add" if not chapter else "replace" + txt = storage.read_text(f"{asset_id}/{num}.txt") + h = hashlib.sha256(txt.encode("utf-8")).hexdigest() + await db.execute( + text( + 'INSERT INTO "ImportCandidateChapter" (id, "jobId", "candidateNumber", "candidateTitle", "candidateHash", "matchedChapterId", action, reason) ' + 'VALUES (:id,:job,:num,:title,:hash,:matched,:action,:reason)' + ), + { + "id": _new_id("ic_"), + "job": job_id, + "num": num, + "title": title, + "hash": h, + "matched": chapter["id"] if chapter else None, + "action": action, + "reason": "number_match" if chapter else "missing_in_novel", + }, + ) + created += 1 + await db.commit() + return {"jobId": job_id, "novelId": payload.novelId, "candidates": created} + + +@app.post("/api/import/jobs/{job_id}/apply") +async def apply_import_job( + job_id: str, + payload: ImportApplyPayload, + 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") + job = (await db.execute(text('SELECT id, "sourceAssetId" FROM "ImportJob" WHERE id = :id LIMIT 1'), {"id": job_id})).mappings().first() + if not job: + raise HTTPException(status_code=404, detail="Import job not found") + asset_id = str(job["sourceAssetId"]) + + rows = (await db.execute(text('SELECT id, "candidateNumber", "matchedChapterId", action FROM "ImportCandidateChapter" WHERE "jobId" = :job ORDER BY "candidateNumber"'), {"job": job_id})).mappings().all() + added = 0 + replaced = 0 + for r in rows: + num = int(r["candidateNumber"]) + do_replace = False + if payload.replaceMode == "selected" and num in payload.selectedChapterNumbers: + do_replace = True + if payload.replaceMode == "range" and payload.rangeStart and payload.rangeEnd and payload.rangeStart <= num <= payload.rangeEnd: + do_replace = True + txt_href = f"{asset_id}/{num}.txt" + raw_href = f"{asset_id}/{num}.raw.html" + txt = storage.read_text(txt_href) + h = hashlib.sha256(txt.encode("utf-8")).hexdigest() + if r["matchedChapterId"] and do_replace: + await db.execute(text('UPDATE "ChapterMeta" SET title = :title WHERE id = :id'), {"id": r["matchedChapterId"], "title": f"Chapter {num}"}) + await db.execute(text('INSERT INTO "ChapterContentRef" ("chapterId", "txtHref", "rawHtmlHref", "contentHash") VALUES (:id,:txt,:raw,:hash) ON CONFLICT ("chapterId") DO UPDATE SET "txtHref"=EXCLUDED."txtHref", "rawHtmlHref"=EXCLUDED."rawHtmlHref", "contentHash"=EXCLUDED."contentHash", "updatedAt"=NOW()'), {"id": r["matchedChapterId"], "txt": txt_href, "raw": raw_href, "hash": h}) + replaced += 1 + elif not r["matchedChapterId"]: + cid = _new_id("cmeta_") + await db.execute(text('INSERT INTO "ChapterMeta" (id, "novelId", number, title, views, "createdAt") VALUES (:id,:novel,:num,:title,0,NOW())'), {"id": cid, "novel": payload.novelId, "num": num, "title": f"Chapter {num}"}) + await db.execute(text('INSERT INTO "ChapterContentRef" ("chapterId", "txtHref", "rawHtmlHref", "contentHash") VALUES (:id,:txt,:raw,:hash)'), {"id": cid, "txt": txt_href, "raw": raw_href, "hash": h}) + added += 1 + await db.commit() + return {"jobId": job_id, "added": added, "replaced": replaced} + + @app.post("/api/import/jobs/{job_id}/apply-mapping") async def apply_import_job_mapping( job_id: str, @@ -1962,6 +5031,10 @@ async def complete_import_job( ), {"asset_id": row["sourceAssetId"], "status": "completed"}, ) + await db.execute( + text('UPDATE "SourceAsset" SET status = :status, review_status = :review_status, "updatedAt" = NOW() WHERE id = :id'), + {"id": row["sourceAssetId"], "status": "completed", "review_status": "imported"}, + ) await db.commit() return {"jobId": job_id, "status": "completed", "sourceAssetId": row["sourceAssetId"]} @@ -2586,15 +5659,18 @@ async def save_user_settings( async def list_recommendations(request: Request, db: AsyncSession = Depends(get_db_session)): user = await require_current_user(request, db) - docs = ( - 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() + try: + docs = ( + 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() + except Exception: + return [] novel_ids = list({doc.get("novelId") for doc in docs if doc.get("novelId")}) novel_map: dict[str, dict[str, Any]] = {} @@ -2649,12 +5725,15 @@ async def create_recommendation( if not novel_exists: raise HTTPException(status_code=404, detail="Truyện không tồn tại") - 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() + try: + 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() + except Exception as exc: + raise HTTPException(status_code=503, detail="Recommendation service is initializing") from exc if existing: raise HTTPException(status_code=409, detail="Bạn đã đề cử truyện này rồi") @@ -2685,12 +5764,15 @@ async def delete_recommendation( ): user = await require_current_user(request, db) - 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() + try: + 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() + except Exception as exc: + raise HTTPException(status_code=503, detail="Recommendation service is initializing") from exc if not existing: raise HTTPException(status_code=404, detail="Bạn chưa đề cử truyện này") diff --git a/app/storage.py b/app/storage.py index 9206f6e..ecf5544 100644 --- a/app/storage.py +++ b/app/storage.py @@ -29,5 +29,19 @@ class NasContentStorage: digest = hashlib.sha256(content.encode("utf-8")).hexdigest() return {"href": href, "sha256": digest, "size": len(content.encode("utf-8"))} + def delete_href(self, href: str) -> bool: + path = self._resolve(href) + if not path.exists() or not path.is_file(): + return False + path.unlink(missing_ok=True) + parent = path.parent + while parent != self.root: + try: + parent.rmdir() + except OSError: + break + parent = parent.parent + return True + storage = NasContentStorage(settings.nas_content_root) diff --git a/docker-compose.yml b/docker-compose.yml index 3361baf..81af31e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,7 @@ services: retries: 5 start_period: 20s - # Local mode API: binds to local Postgres/Mongo containers. + # Local mode API: binds to local Postgres container. api-local: build: context: . @@ -35,7 +35,6 @@ services: - .env environment: DATABASE_URL: postgresql://reader:reader@postgres:5432/reader - MONGODB_URI: mongodb://mongo:27017/reader NAS_CONTENT_ROOT: ${NAS_CONTENT_ROOT:-/data/content} EPUB_SOURCE_ROOT: ${EPUB_SOURCE_ROOT:-/data/epub-source} ports: @@ -53,8 +52,6 @@ services: depends_on: postgres: condition: service_healthy - mongo: - condition: service_healthy web: build: @@ -97,23 +94,8 @@ services: timeout: 5s retries: 10 - mongo: - image: mongo:7 - container_name: reader-api-mongo - profiles: ["localdb"] - ports: - - "27017:27017" - volumes: - - mongo_data:/data/db - healthcheck: - test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] - interval: 10s - timeout: 5s - retries: 10 - volumes: web_uploads: postgres_data: - mongo_data: nas_chapter_content: nas_epub_source: diff --git a/migrations/2026_05_add_chapter_meta.sql b/migrations/2026_05_add_chapter_meta.sql new file mode 100644 index 0000000..28af24d --- /dev/null +++ b/migrations/2026_05_add_chapter_meta.sql @@ -0,0 +1,11 @@ +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, + "createdAt" TIMESTAMPTZ, + UNIQUE("novelId", number) +); + +CREATE INDEX IF NOT EXISTS "ChapterMeta_novel_number_idx" ON "ChapterMeta"("novelId", number); diff --git a/migrations/2026_05_import_search_and_sessions.sql b/migrations/2026_05_import_search_and_sessions.sql new file mode 100644 index 0000000..5b6959a --- /dev/null +++ b/migrations/2026_05_import_search_and_sessions.sql @@ -0,0 +1,39 @@ +DO $$ +BEGIN + CREATE EXTENSION IF NOT EXISTS pg_trgm; +EXCEPTION + WHEN insufficient_privilege THEN NULL; +END; +$$; + +ALTER TABLE "SourceAsset" + ADD COLUMN IF NOT EXISTS search_name TEXT, + ADD COLUMN IF NOT EXISTS size_bytes BIGINT, + ADD COLUMN IF NOT EXISTS mtime_epoch BIGINT, + ADD COLUMN IF NOT EXISTS "lastScannedAt" TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS review_status TEXT NOT NULL DEFAULT 'discovered', + ADD COLUMN IF NOT EXISTS review_payload JSONB; + +UPDATE "SourceAsset" +SET search_name = lower(regexp_replace(path, '[^a-zA-Z0-9\s]', ' ', 'g')) +WHERE search_name IS NULL; + +CREATE INDEX IF NOT EXISTS "SourceAsset_search_name_idx" ON "SourceAsset"(search_name); +CREATE INDEX IF NOT EXISTS "SourceAsset_search_name_trgm_idx" ON "SourceAsset" USING GIN (search_name gin_trgm_ops); +CREATE INDEX IF NOT EXISTS "SourceAsset_status_updatedAt_idx" ON "SourceAsset"(status, "updatedAt" DESC); + +CREATE TABLE IF NOT EXISTS "ImportSession" ( + id TEXT PRIMARY KEY, + "sourceAssetId" TEXT NOT NULL REFERENCES "SourceAsset"(id) ON DELETE CASCADE, + "novelId" TEXT, + status TEXT NOT NULL DEFAULT 'pending', + phase TEXT NOT NULL DEFAULT 'prepare', + "progressPct" DOUBLE PRECISION NOT NULL DEFAULT 0, + log TEXT, + "resultJson" JSONB, + "createdBy" TEXT, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS "ImportSession_asset_status_idx" ON "ImportSession"("sourceAssetId", status, "updatedAt" DESC); diff --git a/pyproject.toml b/pyproject.toml index fb7b302..430dcf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ dependencies = [ "sqlalchemy[asyncio]>=2.0.43", "greenlet>=3.0.0", "asyncpg>=0.30.0", - "motor>=3.7.1", "python-jose[cryptography]>=3.5.0", "google-auth>=2.40.3", "requests>=2.32.5", diff --git a/uv.lock b/uv.lock index f9f70f5..4fc912c 100644 --- a/uv.lock +++ b/uv.lock @@ -90,6 +90,7 @@ dependencies = [ { name = "jmespath" }, { name = "s3transfer" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/74/ec/636ab2aa7ad9e6bf6e297240ac2d44dba63cc6611e2d5038db318436d449/boto3-1.42.74.tar.gz", hash = "sha256:dbacd808cf2a3dadbf35f3dbd8de97b94dc9f78b1ebd439f38f552e0f9753577", size = 112739, upload-time = "2026-03-23T19:34:09.815Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ad/16/a264b4da2af99f4a12609b93fea941cce5ec41da14b33ed3fef77a910f0c/boto3-1.42.74-py3-none-any.whl", hash = "sha256:4bf89c044d618fe4435af854ab820f09dd43569c0df15d7beb0398f50b9aa970", size = 140557, upload-time = "2026-03-23T19:34:07.084Z" }, ] @@ -356,15 +357,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] -[[package]] -name = "dnspython" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, -] - [[package]] name = "ebooklib" version = "0.20" @@ -673,18 +665,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, ] -[[package]] -name = "motor" -version = "3.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pymongo" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/93/ae/96b88362d6a84cb372f7977750ac2a8aed7b2053eed260615df08d5c84f4/motor-3.7.1.tar.gz", hash = "sha256:27b4d46625c87928f331a6ca9d7c51c2f518ba0e270939d395bc1ddc89d64526", size = 280997, upload-time = "2025-05-14T18:56:33.653Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/9a/35e053d4f442addf751ed20e0e922476508ee580786546d699b0567c4c67/motor-3.7.1-py3-none-any.whl", hash = "sha256:8a63b9049e38eeeb56b4fdd57c3312a6d1f25d01db717fe7d82222393c410298", size = 74996, upload-time = "2025-05-14T18:56:31.665Z" }, -] - [[package]] name = "pyasn1" version = "0.6.3" @@ -841,67 +821,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] -[[package]] -name = "pymongo" -version = "4.16.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dnspython" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/65/9c/a4895c4b785fc9865a84a56e14b5bd21ca75aadc3dab79c14187cdca189b/pymongo-4.16.0.tar.gz", hash = "sha256:8ba8405065f6e258a6f872fe62d797a28f383a12178c7153c01ed04e845c600c", size = 2495323, upload-time = "2026-01-07T18:05:48.107Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/3a/907414a763c4270b581ad6d960d0c6221b74a70eda216a1fdd8fa82ba89f/pymongo-4.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6f2077ec24e2f1248f9cac7b9a2dfb894e50cc7939fcebfb1759f99304caabef", size = 862561, upload-time = "2026-01-07T18:04:00.628Z" }, - { url = "https://files.pythonhosted.org/packages/8c/58/787d8225dd65cb2383c447346ea5e200ecfde89962d531111521e3b53018/pymongo-4.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d4f7ba040f72a9f43a44059872af5a8c8c660aa5d7f90d5344f2ed1c3c02721", size = 862923, upload-time = "2026-01-07T18:04:02.213Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a7/cc2865aae32bc77ade7b35f957a58df52680d7f8506f93c6edbf458e5738/pymongo-4.16.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8a0f73af1ea56c422b2dcfc0437459148a799ef4231c6aee189d2d4c59d6728f", size = 1426779, upload-time = "2026-01-07T18:04:03.942Z" }, - { url = "https://files.pythonhosted.org/packages/81/25/3e96eb7998eec05382174da2fefc58d28613f46bbdf821045539d0ed60ab/pymongo-4.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa30cd16ddd2f216d07ba01d9635c873e97ddb041c61cf0847254edc37d1c60e", size = 1454207, upload-time = "2026-01-07T18:04:05.387Z" }, - { url = "https://files.pythonhosted.org/packages/86/7b/8e817a7df8c5d565d39dd4ca417a5e0ef46cc5cc19aea9405f403fec6449/pymongo-4.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d638b0b1b294d95d0fdc73688a3b61e05cc4188872818cd240d51460ccabcb5", size = 1511654, upload-time = "2026-01-07T18:04:08.458Z" }, - { url = "https://files.pythonhosted.org/packages/39/7a/50c4d075ccefcd281cdcfccc5494caa5665b096b85e65a5d6afabb80e09e/pymongo-4.16.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:21d02cc10a158daa20cb040985e280e7e439832fc6b7857bff3d53ef6914ad50", size = 1496794, upload-time = "2026-01-07T18:04:10.355Z" }, - { url = "https://files.pythonhosted.org/packages/0f/cd/ebdc1aaca5deeaf47310c369ef4083e8550e04e7bf7e3752cfb7d95fcdb8/pymongo-4.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fbb8d3552c2ad99d9e236003c0b5f96d5f05e29386ba7abae73949bfebc13dd", size = 1448371, upload-time = "2026-01-07T18:04:11.76Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c9/50fdd78c37f68ea49d590c027c96919fbccfd98f3a4cb39f84f79970bd37/pymongo-4.16.0-cp311-cp311-win32.whl", hash = "sha256:be1099a8295b1a722d03fb7b48be895d30f4301419a583dcf50e9045968a041c", size = 841024, upload-time = "2026-01-07T18:04:13.522Z" }, - { url = "https://files.pythonhosted.org/packages/4a/dd/a3aa1ade0cf9980744db703570afac70a62c85b432c391dea0577f6da7bb/pymongo-4.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:61567f712bda04c7545a037e3284b4367cad8d29b3dec84b4bf3b2147020a75b", size = 855838, upload-time = "2026-01-07T18:04:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/bf/10/9ad82593ccb895e8722e4884bad4c5ce5e8ff6683b740d7823a6c2bcfacf/pymongo-4.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:c53338613043038005bf2e41a2fafa08d29cdbc0ce80891b5366c819456c1ae9", size = 845007, upload-time = "2026-01-07T18:04:17.099Z" }, - { url = "https://files.pythonhosted.org/packages/6a/03/6dd7c53cbde98de469a3e6fb893af896dca644c476beb0f0c6342bcc368b/pymongo-4.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bd4911c40a43a821dfd93038ac824b756b6e703e26e951718522d29f6eb166a8", size = 917619, upload-time = "2026-01-07T18:04:19.173Z" }, - { url = "https://files.pythonhosted.org/packages/73/e1/328915f2734ea1f355dc9b0e98505ff670f5fab8be5e951d6ed70971c6aa/pymongo-4.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25a6b03a68f9907ea6ec8bc7cf4c58a1b51a18e23394f962a6402f8e46d41211", size = 917364, upload-time = "2026-01-07T18:04:20.861Z" }, - { url = "https://files.pythonhosted.org/packages/41/fe/4769874dd9812a1bc2880a9785e61eba5340da966af888dd430392790ae0/pymongo-4.16.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:91ac0cb0fe2bf17616c2039dac88d7c9a5088f5cb5829b27c9d250e053664d31", size = 1686901, upload-time = "2026-01-07T18:04:22.219Z" }, - { url = "https://files.pythonhosted.org/packages/fa/8d/15707b9669fdc517bbc552ac60da7124dafe7ac1552819b51e97ed4038b4/pymongo-4.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf0ec79e8ca7077f455d14d915d629385153b6a11abc0b93283ed73a8013e376", size = 1723034, upload-time = "2026-01-07T18:04:24.055Z" }, - { url = "https://files.pythonhosted.org/packages/5b/af/3d5d16ff11d447d40c1472da1b366a31c7380d7ea2922a449c7f7f495567/pymongo-4.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2d0082631a7510318befc2b4fdab140481eb4b9dd62d9245e042157085da2a70", size = 1797161, upload-time = "2026-01-07T18:04:25.964Z" }, - { url = "https://files.pythonhosted.org/packages/fb/04/725ab8664eeec73ec125b5a873448d80f5d8cf2750aaaf804cbc538a50a5/pymongo-4.16.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85dc2f3444c346ea019a371e321ac868a4fab513b7a55fe368f0cc78de8177cc", size = 1780938, upload-time = "2026-01-07T18:04:28.745Z" }, - { url = "https://files.pythonhosted.org/packages/22/50/dd7e9095e1ca35f93c3c844c92eb6eb0bc491caeb2c9bff3b32fe3c9b18f/pymongo-4.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dabbf3c14de75a20cc3c30bf0c6527157224a93dfb605838eabb1a2ee3be008d", size = 1714342, upload-time = "2026-01-07T18:04:30.331Z" }, - { url = "https://files.pythonhosted.org/packages/03/c9/542776987d5c31ae8e93e92680ea2b6e5a2295f398b25756234cabf38a39/pymongo-4.16.0-cp312-cp312-win32.whl", hash = "sha256:60307bb91e0ab44e560fe3a211087748b2b5f3e31f403baf41f5b7b0a70bd104", size = 887868, upload-time = "2026-01-07T18:04:32.124Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d4/b4045a7ccc5680fb496d01edf749c7a9367cc8762fbdf7516cf807ef679b/pymongo-4.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:f513b2c6c0d5c491f478422f6b5b5c27ac1af06a54c93ef8631806f7231bd92e", size = 907554, upload-time = "2026-01-07T18:04:33.685Z" }, - { url = "https://files.pythonhosted.org/packages/60/4c/33f75713d50d5247f2258405142c0318ff32c6f8976171c4fcae87a9dbdf/pymongo-4.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:dfc320f08ea9a7ec5b2403dc4e8150636f0d6150f4b9792faaae539c88e7db3b", size = 892971, upload-time = "2026-01-07T18:04:35.594Z" }, - { url = "https://files.pythonhosted.org/packages/47/84/148d8b5da8260f4679d6665196ae04ab14ffdf06f5fe670b0ab11942951f/pymongo-4.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d15f060bc6d0964a8bb70aba8f0cb6d11ae99715438f640cff11bbcf172eb0e8", size = 972009, upload-time = "2026-01-07T18:04:38.303Z" }, - { url = "https://files.pythonhosted.org/packages/1e/5e/9f3a8daf583d0adaaa033a3e3e58194d2282737dc164014ff33c7a081103/pymongo-4.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a19ea46a0fe71248965305a020bc076a163311aefbaa1d83e47d06fa30ac747", size = 971784, upload-time = "2026-01-07T18:04:39.669Z" }, - { url = "https://files.pythonhosted.org/packages/ad/f2/b6c24361fcde24946198573c0176406bfd5f7b8538335f3d939487055322/pymongo-4.16.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:311d4549d6bf1f8c61d025965aebb5ba29d1481dc6471693ab91610aaffbc0eb", size = 1947174, upload-time = "2026-01-07T18:04:41.368Z" }, - { url = "https://files.pythonhosted.org/packages/47/1a/8634192f98cf740b3d174e1018dd0350018607d5bd8ac35a666dc49c732b/pymongo-4.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46ffb728d92dd5b09fc034ed91acf5595657c7ca17d4cf3751322cd554153c17", size = 1991727, upload-time = "2026-01-07T18:04:42.965Z" }, - { url = "https://files.pythonhosted.org/packages/5a/2f/0c47ac84572b28e23028a23a3798a1f725e1c23b0cf1c1424678d16aff42/pymongo-4.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:acda193f440dd88c2023cb00aa8bd7b93a9df59978306d14d87a8b12fe426b05", size = 2082497, upload-time = "2026-01-07T18:04:44.652Z" }, - { url = "https://files.pythonhosted.org/packages/ba/57/9f46ef9c862b2f0cf5ce798f3541c201c574128d31ded407ba4b3918d7b6/pymongo-4.16.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d9fdb386cf958e6ef6ff537d6149be7edb76c3268cd6833e6c36aa447e4443f", size = 2064947, upload-time = "2026-01-07T18:04:46.228Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/5421c0998f38e32288100a07f6cb2f5f9f352522157c901910cb2927e211/pymongo-4.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91899dd7fb9a8c50f09c3c1cf0cb73bfbe2737f511f641f19b9650deb61c00ca", size = 1980478, upload-time = "2026-01-07T18:04:48.017Z" }, - { url = "https://files.pythonhosted.org/packages/92/93/bfc448d025e12313a937d6e1e0101b50cc9751636b4b170e600fe3203063/pymongo-4.16.0-cp313-cp313-win32.whl", hash = "sha256:2cd60cd1e05de7f01927f8e25ca26b3ea2c09de8723241e5d3bcfdc70eaff76b", size = 934672, upload-time = "2026-01-07T18:04:49.538Z" }, - { url = "https://files.pythonhosted.org/packages/96/10/12710a5e01218d50c3dd165fd72c5ed2699285f77348a3b1a119a191d826/pymongo-4.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3ead8a0050c53eaa55935895d6919d393d0328ec24b2b9115bdbe881aa222673", size = 959237, upload-time = "2026-01-07T18:04:51.382Z" }, - { url = "https://files.pythonhosted.org/packages/0c/56/d288bcd1d05bc17ec69df1d0b1d67bc710c7c5dbef86033a5a4d2e2b08e6/pymongo-4.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:dbbc5b254c36c37d10abb50e899bc3939bbb7ab1e7c659614409af99bd3e7675", size = 940909, upload-time = "2026-01-07T18:04:52.904Z" }, - { url = "https://files.pythonhosted.org/packages/30/9e/4d343f8d0512002fce17915a89477b9f916bda1205729e042d8f23acf194/pymongo-4.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8a254d49a9ffe9d7f888e3c677eed3729b14ce85abb08cd74732cead6ccc3c66", size = 1026634, upload-time = "2026-01-07T18:04:54.359Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e3/341f88c5535df40c0450fda915f582757bb7d988cdfc92990a5e27c4c324/pymongo-4.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a1bf44e13cf2d44d2ea2e928a8140d5d667304abe1a61c4d55b4906f389fbe64", size = 1026252, upload-time = "2026-01-07T18:04:56.642Z" }, - { url = "https://files.pythonhosted.org/packages/af/64/9471b22eb98f0a2ca0b8e09393de048502111b2b5b14ab1bd9e39708aab5/pymongo-4.16.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f1c5f1f818b669875d191323a48912d3fcd2e4906410e8297bb09ac50c4d5ccc", size = 2207399, upload-time = "2026-01-07T18:04:58.255Z" }, - { url = "https://files.pythonhosted.org/packages/87/ac/47c4d50b25a02f21764f140295a2efaa583ee7f17992a5e5fa542b3a690f/pymongo-4.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77cfd37a43a53b02b7bd930457c7994c924ad8bbe8dff91817904bcbf291b371", size = 2260595, upload-time = "2026-01-07T18:04:59.788Z" }, - { url = "https://files.pythonhosted.org/packages/ee/1b/0ce1ce9dd036417646b2fe6f63b58127acff3cf96eeb630c34ec9cd675ff/pymongo-4.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:36ef2fee50eee669587d742fb456e349634b4fcf8926208766078b089054b24b", size = 2366958, upload-time = "2026-01-07T18:05:01.942Z" }, - { url = "https://files.pythonhosted.org/packages/3e/3c/a5a17c0d413aa9d6c17bc35c2b472e9e79cda8068ba8e93433b5f43028e9/pymongo-4.16.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55f8d5a6fe2fa0b823674db2293f92d74cd5f970bc0360f409a1fc21003862d3", size = 2346081, upload-time = "2026-01-07T18:05:03.576Z" }, - { url = "https://files.pythonhosted.org/packages/65/19/f815533d1a88fb8a3b6c6e895bb085ffdae68ccb1e6ed7102202a307f8e2/pymongo-4.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9caacac0dd105e2555521002e2d17afc08665187017b466b5753e84c016628e6", size = 2246053, upload-time = "2026-01-07T18:05:05.459Z" }, - { url = "https://files.pythonhosted.org/packages/c6/88/4be3ec78828dc64b212c123114bd6ae8db5b7676085a7b43cc75d0131bd2/pymongo-4.16.0-cp314-cp314-win32.whl", hash = "sha256:c789236366525c3ee3cd6e4e450a9ff629a7d1f4d88b8e18a0aea0615fd7ecf8", size = 989461, upload-time = "2026-01-07T18:05:07.018Z" }, - { url = "https://files.pythonhosted.org/packages/af/5a/ab8d5af76421b34db483c9c8ebc3a2199fb80ae63dc7e18f4cf1df46306a/pymongo-4.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:2b0714d7764efb29bf9d3c51c964aed7c4c7237b341f9346f15ceaf8321fdb35", size = 1017803, upload-time = "2026-01-07T18:05:08.499Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f4/98d68020728ac6423cf02d17cfd8226bf6cce5690b163d30d3f705e8297e/pymongo-4.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:12762e7cc0f8374a8cae3b9f9ed8dabb5d438c7b33329232dd9b7de783454033", size = 997184, upload-time = "2026-01-07T18:05:09.944Z" }, - { url = "https://files.pythonhosted.org/packages/50/00/dc3a271daf06401825b9c1f4f76f018182c7738281ea54b9762aea0560c1/pymongo-4.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1c01e8a7cd0ea66baf64a118005535ab5bf9f9eb63a1b50ac3935dccf9a54abe", size = 1083303, upload-time = "2026-01-07T18:05:11.702Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4b/b5375ee21d12eababe46215011ebc63801c0d2c5ffdf203849d0d79f9852/pymongo-4.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4c4872299ebe315a79f7f922051061634a64fda95b6b17677ba57ef00b2ba2a4", size = 1083233, upload-time = "2026-01-07T18:05:13.182Z" }, - { url = "https://files.pythonhosted.org/packages/ee/e3/52efa3ca900622c7dcb56c5e70f15c906816d98905c22d2ee1f84d9a7b60/pymongo-4.16.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:78037d02389745e247fe5ab0bcad5d1ab30726eaac3ad79219c7d6bbb07eec53", size = 2527438, upload-time = "2026-01-07T18:05:14.981Z" }, - { url = "https://files.pythonhosted.org/packages/cb/96/43b1be151c734e7766c725444bcbfa1de6b60cc66bfb406203746839dd25/pymongo-4.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c126fb72be2518395cc0465d4bae03125119136462e1945aea19840e45d89cfc", size = 2600399, upload-time = "2026-01-07T18:05:16.794Z" }, - { url = "https://files.pythonhosted.org/packages/e7/62/fa64a5045dfe3a1cd9217232c848256e7bc0136cffb7da4735c5e0d30e40/pymongo-4.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3867dc225d9423c245a51eaac2cfcd53dde8e0a8d8090bb6aed6e31bd6c2d4f", size = 2720960, upload-time = "2026-01-07T18:05:18.498Z" }, - { url = "https://files.pythonhosted.org/packages/54/7b/01577eb97e605502821273a5bc16ce0fb0be5c978fe03acdbff471471202/pymongo-4.16.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f25001a955073b80510c0c3db0e043dbbc36904fd69e511c74e3d8640b8a5111", size = 2699344, upload-time = "2026-01-07T18:05:20.073Z" }, - { url = "https://files.pythonhosted.org/packages/55/68/6ef6372d516f703479c3b6cbbc45a5afd307173b1cbaccd724e23919bb1a/pymongo-4.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d9885aad05f82fd7ea0c9ca505d60939746b39263fa273d0125170da8f59098", size = 2577133, upload-time = "2026-01-07T18:05:22.052Z" }, - { url = "https://files.pythonhosted.org/packages/15/c7/b5337093bb01da852f945802328665f85f8109dbe91d81ea2afe5ff059b9/pymongo-4.16.0-cp314-cp314t-win32.whl", hash = "sha256:948152b30eddeae8355495f9943a3bf66b708295c0b9b6f467de1c620f215487", size = 1040560, upload-time = "2026-01-07T18:05:23.888Z" }, - { url = "https://files.pythonhosted.org/packages/96/8c/5b448cd1b103f3889d5713dda37304c81020ff88e38a826e8a75ddff4610/pymongo-4.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f6e42c1bc985d9beee884780ae6048790eb4cd565c46251932906bdb1630034a", size = 1075081, upload-time = "2026-01-07T18:05:26.874Z" }, - { url = "https://files.pythonhosted.org/packages/32/cd/ddc794cdc8500f6f28c119c624252fb6dfb19481c6d7ed150f13cf468a6d/pymongo-4.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6b2a20edb5452ac8daa395890eeb076c570790dfce6b7a44d788af74c2f8cf96", size = 1047725, upload-time = "2026-01-07T18:05:28.47Z" }, -] - [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1031,7 +950,6 @@ dependencies = [ { name = "greenlet" }, { name = "html2text" }, { name = "httpx" }, - { name = "motor" }, { name = "pydantic-settings" }, { name = "python-jose", extra = ["cryptography"] }, { name = "python-multipart" }, @@ -1056,7 +974,6 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.0.0" }, { name = "html2text", specifier = ">=2024.2.26" }, { name = "httpx", specifier = ">=0.27.0" }, - { name = "motor", specifier = ">=3.7.1" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, { name = "python-multipart", specifier = ">=0.0.9" },