chore: remove legacy tables and series table scripts
Build and Push Reader API Image / docker (push) Successful in 38s
Build and Push Reader API Image / docker (push) Successful in 38s
- Removed mongoose dependency from package-lock.json. - Deleted legacy import tables: ImportCandidateChapter, AssetNovelMapping, ImportJob, ImportSession, SourceAsset via new script `drop_legacy_import_tables.py`. - Added script `drop_series_table.py` to drop the Series table. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -6,6 +6,7 @@ __pycache__/
|
||||
.venv/
|
||||
dist/
|
||||
build/
|
||||
node_modules/
|
||||
|
||||
.DS_Store
|
||||
.env
|
||||
@@ -17,3 +18,4 @@ test_*.py
|
||||
data/epub-source/*
|
||||
data/content/*
|
||||
data/nas-content/*
|
||||
node_modules
|
||||
|
||||
@@ -28,8 +28,6 @@ class Settings(BaseSettings):
|
||||
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]:
|
||||
|
||||
+337
-282
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
@@ -10,6 +11,8 @@ import re
|
||||
import secrets
|
||||
import tempfile
|
||||
import uuid
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from difflib import SequenceMatcher
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
@@ -34,18 +37,9 @@ 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:
|
||||
@@ -191,7 +185,18 @@ async def _ensure_migration_tables() -> None:
|
||||
|
||||
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
||||
|
||||
_SCAN_TASK: asyncio.Task[Any] | None = None
|
||||
|
||||
@app.middleware("http")
|
||||
async def disable_legacy_import_routes(request: Request, call_next):
|
||||
path = request.url.path
|
||||
if path.startswith("/api/import") and path != "/api/import/uploads/preview":
|
||||
return Response(
|
||||
content=json.dumps({"detail": "Legacy import endpoints are removed"}),
|
||||
status_code=410,
|
||||
media_type="application/json",
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
_IMPORT_TASKS: set[asyncio.Task[Any]] = set()
|
||||
|
||||
|
||||
@@ -202,87 +207,6 @@ def _normalized_search_name(value: str) -> str:
|
||||
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,
|
||||
@@ -1017,32 +941,10 @@ async def _resolve_series_id(
|
||||
series_id: str | None,
|
||||
series_name: str | None,
|
||||
) -> str | None:
|
||||
_ = db
|
||||
_ = series_name
|
||||
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
|
||||
return sid or None
|
||||
|
||||
|
||||
async def _set_novel_genres(db: AsyncSession, novel_id: str, genre_ids: list[str]) -> None:
|
||||
@@ -1116,108 +1018,8 @@ async def _delete_novel_by_id(db: AsyncSession, novel_id: str) -> bool:
|
||||
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(
|
||||
@@ -1230,8 +1032,8 @@ async def mod_list_novels(
|
||||
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" '
|
||||
'NULL::text AS series_id, NULL::text AS series_name, NULL::text AS series_slug '
|
||||
'FROM "Novel" n '
|
||||
'ORDER BY n."updatedAt" DESC, n."createdAt" DESC'
|
||||
)
|
||||
)
|
||||
@@ -1268,8 +1070,8 @@ async def mod_get_novel_detail(
|
||||
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'
|
||||
'NULL::text AS series_id, NULL::text AS series_name, NULL::text AS series_slug '
|
||||
'FROM "Novel" n WHERE n.id = :id LIMIT 1'
|
||||
),
|
||||
{"id": novel_id},
|
||||
)
|
||||
@@ -1483,15 +1285,15 @@ async def mod_list_missing_novels(
|
||||
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_parts.append('(n.title ILIKE :q OR n.slug ILIKE :q OR n."authorName" 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" '
|
||||
'NULL::text AS series_id, NULL::text AS series_name, NULL::text AS series_slug '
|
||||
'FROM "Novel" n '
|
||||
f'{where_sql} '
|
||||
'ORDER BY n."updatedAt" DESC, n.title ASC LIMIT 2000'
|
||||
),
|
||||
@@ -1609,7 +1411,7 @@ async def mod_overview(
|
||||
novel_count = (await db.execute(text('SELECT COUNT(*)::int FROM "Novel"'))).scalar_one()
|
||||
total_views = (await db.execute(text('SELECT COALESCE(SUM(views),0)::int FROM "Novel"'))).scalar_one()
|
||||
comment_count = (await db.execute(text('SELECT COUNT(*)::int FROM "Comment"'))).scalar_one()
|
||||
series_count = (await db.execute(text('SELECT COUNT(*)::int FROM "Series"'))).scalar_one()
|
||||
series_count = 0
|
||||
return {
|
||||
"novelCount": int(novel_count or 0),
|
||||
"totalViews": int(total_views or 0),
|
||||
@@ -2192,10 +1994,25 @@ async def mod_epub_upload(
|
||||
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))
|
||||
epub_meta = _extract_epub_metadata(tmp_path)
|
||||
inferred_title = str(epub_meta.get("title") or Path(file.filename or "novel").stem)
|
||||
inferred_author = str(epub_meta.get("author") or "Unknown")
|
||||
inferred_desc = str(epub_meta.get("description") or "")
|
||||
inferred_genres = [str(g).strip() for g in (epub_meta.get("genres") or []) if str(g).strip()]
|
||||
|
||||
base_title = " ".join((title or inferred_title).split()).strip() or "Untitled"
|
||||
base_author = " ".join((authorName or inferred_author).split()).strip() or "Unknown"
|
||||
base_desc = (description if description is not None else inferred_desc).strip()
|
||||
cover_extracted = _extract_epub_cover(tmp_path) or _extract_epub_cover_from_zip(tmp_path)
|
||||
has_cover = bool(cover_extracted)
|
||||
cover_preview_data_url: str | None = None
|
||||
uploaded_cover_url: str | None = None
|
||||
if cover_extracted:
|
||||
cover_bytes, cover_ext = cover_extracted
|
||||
cover_ext = _guess_image_extension(cover_bytes)
|
||||
mime = _mime_from_extension(cover_ext)
|
||||
cover_preview_data_url = f"data:{mime};base64,{base64.b64encode(cover_bytes).decode('ascii')}"
|
||||
uploaded_cover_url = _upload_cover_bytes_to_r2(cover_bytes, cover_ext, key_prefix=f"epub-cover-{_new_id()}")
|
||||
|
||||
if str(preview or "").lower() == "true":
|
||||
return {
|
||||
@@ -2204,6 +2021,7 @@ async def mod_epub_upload(
|
||||
"splitMode": mode,
|
||||
"detectedStructureType": "standard",
|
||||
"hasCoverFromEpub": has_cover,
|
||||
"coverPreviewDataUrl": cover_preview_data_url,
|
||||
"parserInfo": {
|
||||
"splitMode": mode,
|
||||
"chapterRegexUsed": pattern,
|
||||
@@ -2218,7 +2036,7 @@ async def mod_epub_upload(
|
||||
"title": base_title,
|
||||
"authorName": base_author,
|
||||
"description": base_desc,
|
||||
"detectedGenres": [],
|
||||
"detectedGenres": inferred_genres,
|
||||
"totalChapters": len(chapters),
|
||||
},
|
||||
"chaptersPreview": [
|
||||
@@ -2309,12 +2127,12 @@ async def mod_epub_upload(
|
||||
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'),
|
||||
text('UPDATE "Novel" SET "authorName" = :author, description = :desc, "coverUrl" = COALESCE(:cover, "coverUrl"), "seriesId" = :series_id, "updatedAt" = NOW() WHERE id = :id'),
|
||||
{
|
||||
"id": novel_id,
|
||||
"author": base_author,
|
||||
"desc": base_desc,
|
||||
"cover": None,
|
||||
"cover": uploaded_cover_url,
|
||||
"series_id": target_series_id,
|
||||
},
|
||||
)
|
||||
@@ -2329,7 +2147,7 @@ async def mod_epub_upload(
|
||||
"slug": slug,
|
||||
"author": base_author,
|
||||
"desc": base_desc,
|
||||
"cover": None,
|
||||
"cover": uploaded_cover_url,
|
||||
"status": "Đang ra",
|
||||
"series_id": target_series_id,
|
||||
},
|
||||
@@ -2418,7 +2236,7 @@ async def browse_novels(
|
||||
params["q"] = f"%{q.strip()}%"
|
||||
where_parts.append(
|
||||
'(n.title ILIKE :q OR n."originalTitle" ILIKE :q OR n."authorName" ILIKE :q '
|
||||
'OR n."originalAuthorName" ILIKE :q OR s.name ILIKE :q)'
|
||||
'OR n."originalAuthorName" ILIKE :q)'
|
||||
)
|
||||
|
||||
where_sql = f"WHERE {' AND '.join(where_parts)}" if where_parts else ""
|
||||
@@ -2426,11 +2244,10 @@ async def browse_novels(
|
||||
base_select = (
|
||||
'n.id, n.title, n.slug, n."originalTitle", n."authorName", n."coverUrl", n."coverColor", '
|
||||
'n.status, n."totalChapters", n.views, n.rating, n."ratingCount", n."bookmarkCount", '
|
||||
'n."seriesId", s.id AS series_id, s.name AS series_name, s.slug AS series_slug, n."updatedAt"'
|
||||
'n."seriesId", NULL::text AS series_id, NULL::text AS series_name, NULL::text AS series_slug, n."updatedAt"'
|
||||
)
|
||||
base_from = (
|
||||
'FROM "Novel" n '
|
||||
'LEFT JOIN "Series" s ON s.id = n."seriesId" '
|
||||
)
|
||||
|
||||
if collapse_series:
|
||||
@@ -2549,9 +2366,8 @@ async def get_novel_detail(id_or_slug: str, db: AsyncSession = Depends(get_db_se
|
||||
'SELECT n.id, n.title, n.slug, n."originalTitle", n."authorName", n."originalAuthorName", '
|
||||
'n.description, n."coverUrl", n."coverColor", n.status, n."totalChapters", n.views, n.rating, '
|
||||
'n."ratingCount", n."bookmarkCount", n."seriesId", n."createdAt", n."updatedAt", '
|
||||
's.id AS series_id, s.name AS series_name, s.slug AS series_slug '
|
||||
'NULL::text AS series_id, NULL::text AS series_name, NULL::text AS series_slug '
|
||||
'FROM "Novel" n '
|
||||
'LEFT JOIN "Series" s ON s.id = n."seriesId" '
|
||||
'WHERE n.id = :value OR n.slug = :value '
|
||||
'LIMIT 1'
|
||||
),
|
||||
@@ -2761,10 +2577,9 @@ async def suggest_novels(q: str = "", db: AsyncSession = Depends(get_db_session)
|
||||
rows = (
|
||||
await db.execute(
|
||||
text(
|
||||
'SELECT n.id, n.title, n.slug, n."authorName", n."coverUrl", s.id AS series_id, s.name AS series_name '
|
||||
'SELECT n.id, n.title, n.slug, n."authorName", n."coverUrl", NULL::text AS series_id, NULL::text AS series_name '
|
||||
'FROM "Novel" n '
|
||||
'LEFT JOIN "Series" s ON s.id = n."seriesId" '
|
||||
'WHERE n.title ILIKE :q OR n."authorName" ILIKE :q OR s.name ILIKE :q '
|
||||
'WHERE n.title ILIKE :q OR n."authorName" ILIKE :q '
|
||||
'ORDER BY n.views DESC, n."updatedAt" DESC '
|
||||
'LIMIT 8'
|
||||
),
|
||||
@@ -2874,12 +2689,6 @@ class SourceAssetAiSuggestPayload(BaseModel):
|
||||
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
|
||||
@@ -3308,6 +3117,21 @@ def _extract_epub_cover(epub_path: Path) -> tuple[bytes, str] | None:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
direct_cover = book.get_cover()
|
||||
if direct_cover and len(direct_cover) >= 2:
|
||||
cover_bytes = direct_cover[1]
|
||||
if cover_bytes:
|
||||
name = str(direct_cover[0] or "").lower()
|
||||
ext = ".jpg"
|
||||
if name.endswith(".png"):
|
||||
ext = ".png"
|
||||
elif name.endswith(".webp"):
|
||||
ext = ".webp"
|
||||
return cover_bytes, ext
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for item in book.get_items():
|
||||
try:
|
||||
media_type = str(getattr(item, "media_type", "") or "")
|
||||
@@ -3354,6 +3178,208 @@ def _extract_epub_cover(epub_path: Path) -> tuple[bytes, str] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_epub_cover_from_zip(epub_path: Path) -> tuple[bytes, str] | None:
|
||||
try:
|
||||
with zipfile.ZipFile(epub_path, "r") as zf:
|
||||
names = zf.namelist()
|
||||
lower_map = {name.lower(): name for name in names}
|
||||
preferred = [
|
||||
"cover.jpg", "cover.jpeg", "cover.png", "cover.webp",
|
||||
"images/cover.jpg", "images/cover.jpeg", "images/cover.png", "images/cover.webp",
|
||||
"oebps/cover.jpg", "oebps/cover.jpeg", "oebps/cover.png", "oebps/cover.webp",
|
||||
]
|
||||
for candidate in preferred:
|
||||
actual = lower_map.get(candidate)
|
||||
if not actual:
|
||||
continue
|
||||
data = zf.read(actual)
|
||||
if data:
|
||||
return data, _guess_image_extension(data)
|
||||
|
||||
for name in names:
|
||||
low = name.lower()
|
||||
if not low.endswith((".jpg", ".jpeg", ".png", ".webp", ".gif")):
|
||||
continue
|
||||
if "cover" not in low:
|
||||
continue
|
||||
data = zf.read(name)
|
||||
if data:
|
||||
return data, _guess_image_extension(data)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _extract_epub_metadata(epub_path: Path) -> dict[str, Any]:
|
||||
from ebooklib import epub as epublib
|
||||
|
||||
try:
|
||||
book = epublib.read_epub(str(epub_path), options={"ignore_ncx": False})
|
||||
except Exception:
|
||||
return {"title": None, "author": None, "description": None, "genres": []}
|
||||
|
||||
def _first_text(namespace: str, key: str) -> str | None:
|
||||
try:
|
||||
values = book.get_metadata(namespace, key)
|
||||
except Exception:
|
||||
values = []
|
||||
for value in values or []:
|
||||
raw = value[0] if isinstance(value, tuple) else value
|
||||
text_value = str(raw or "").strip()
|
||||
if text_value:
|
||||
return text_value
|
||||
return None
|
||||
|
||||
title = _first_text("DC", "title")
|
||||
author = _first_text("DC", "creator")
|
||||
description = _first_text("DC", "description")
|
||||
|
||||
subjects: list[str] = []
|
||||
try:
|
||||
for value in book.get_metadata("DC", "subject") or []:
|
||||
raw = value[0] if isinstance(value, tuple) else value
|
||||
text_value = str(raw or "").strip()
|
||||
if text_value:
|
||||
subjects.append(text_value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = {
|
||||
"title": title,
|
||||
"author": author,
|
||||
"description": description,
|
||||
"genres": subjects[:8],
|
||||
}
|
||||
if result["title"] or result["author"] or result["description"] or result["genres"]:
|
||||
return result
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(epub_path, "r") as zf:
|
||||
container_xml = zf.read("META-INF/container.xml")
|
||||
croot = ET.fromstring(container_xml)
|
||||
rootfile = croot.find('.//{*}rootfile')
|
||||
if rootfile is None:
|
||||
return result
|
||||
opf_path = rootfile.attrib.get("full-path")
|
||||
if not opf_path:
|
||||
return result
|
||||
opf_xml = zf.read(opf_path)
|
||||
oroot = ET.fromstring(opf_xml)
|
||||
t = oroot.find('.//{*}title')
|
||||
a = oroot.find('.//{*}creator')
|
||||
d = oroot.find('.//{*}description')
|
||||
s = oroot.findall('.//{*}subject')
|
||||
title2 = (t.text or "").strip() if t is not None and t.text else None
|
||||
author2 = (a.text or "").strip() if a is not None and a.text else None
|
||||
desc2 = (d.text or "").strip() if d is not None and d.text else None
|
||||
genres2 = [str(x.text or "").strip() for x in s if x is not None and str(x.text or "").strip()][:8]
|
||||
return {
|
||||
"title": title2,
|
||||
"author": author2,
|
||||
"description": desc2,
|
||||
"genres": genres2,
|
||||
}
|
||||
except Exception:
|
||||
return result
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _guess_image_extension(image_bytes: bytes) -> str:
|
||||
if image_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return ".png"
|
||||
if image_bytes.startswith(b"RIFF") and b"WEBP" in image_bytes[:16]:
|
||||
return ".webp"
|
||||
if image_bytes.startswith(b"GIF87a") or image_bytes.startswith(b"GIF89a"):
|
||||
return ".gif"
|
||||
if image_bytes.startswith(b"\xff\xd8\xff"):
|
||||
return ".jpg"
|
||||
return ".jpg"
|
||||
|
||||
|
||||
def _mime_from_extension(ext: str) -> str:
|
||||
if ext == ".png":
|
||||
return "image/png"
|
||||
if ext == ".webp":
|
||||
return "image/webp"
|
||||
if ext == ".gif":
|
||||
return "image/gif"
|
||||
return "image/jpeg"
|
||||
|
||||
|
||||
def _resolve_epub_source_path(asset_path: str, sha256_hint: str | None = None) -> Path | None:
|
||||
raw = str(asset_path or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
direct = Path(raw)
|
||||
if direct.exists():
|
||||
return direct
|
||||
|
||||
root = Path(settings.epub_source_root)
|
||||
candidate = root / raw
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
normalized = raw.replace("\\", "/")
|
||||
candidate2 = root / normalized
|
||||
if candidate2.exists():
|
||||
return candidate2
|
||||
|
||||
basename = Path(normalized).name
|
||||
if basename:
|
||||
try:
|
||||
matches = list(root.rglob(basename))
|
||||
if matches:
|
||||
return matches[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if sha256_hint:
|
||||
target_sha = str(sha256_hint).strip().lower()
|
||||
if target_sha:
|
||||
try:
|
||||
for candidate in root.rglob("*.epub"):
|
||||
try:
|
||||
if _asset_file_sha256(candidate).lower() == target_sha:
|
||||
return candidate
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_epub_preview_payload(epub_path: Path) -> dict[str, Any]:
|
||||
cover = _extract_epub_cover(epub_path) or _extract_epub_cover_from_zip(epub_path)
|
||||
cover_bytes: bytes | None = None
|
||||
cover_ext: str | None = None
|
||||
cover_data_url: str | None = None
|
||||
if cover:
|
||||
cover_bytes, cover_ext = cover
|
||||
cover_ext = _guess_image_extension(cover_bytes)
|
||||
mime = _mime_from_extension(cover_ext)
|
||||
cover_data_url = f"data:{mime};base64,{base64.b64encode(cover_bytes).decode('ascii')}"
|
||||
|
||||
meta = _extract_epub_metadata(epub_path)
|
||||
title = str(meta.get("title") or "").strip() or epub_path.stem
|
||||
author = str(meta.get("author") or "").strip() or "Unknown"
|
||||
description = str(meta.get("description") or "").strip()
|
||||
genres = [str(g).strip() for g in (meta.get("genres") or []) if str(g).strip()][:8]
|
||||
|
||||
return {
|
||||
"coverFound": bool(cover_bytes),
|
||||
"coverBytes": cover_bytes,
|
||||
"coverExt": cover_ext,
|
||||
"coverPreviewDataUrl": cover_data_url,
|
||||
"title": title,
|
||||
"author": author,
|
||||
"description": description,
|
||||
"genres": genres,
|
||||
}
|
||||
|
||||
|
||||
def _upload_cover_bytes_to_r2(image_bytes: bytes, extension: str, *, key_prefix: str) -> str | None:
|
||||
if not image_bytes:
|
||||
return None
|
||||
@@ -3719,19 +3745,63 @@ async def preview_source_asset_metadata(
|
||||
|
||||
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
|
||||
source_path = _resolve_epub_source_path(path, str(row.get("sha256") or ""))
|
||||
preview = _extract_epub_preview_payload(source_path) if source_path else None
|
||||
return {
|
||||
"asset": {**dict(row), "coverDetected": cover_detected},
|
||||
"asset": {**dict(row), "coverDetected": bool(preview and preview.get("coverFound"))},
|
||||
"suggested": {
|
||||
"title": row.get("title") or base,
|
||||
"author": row.get("author") or "Unknown",
|
||||
"shortDescription": None,
|
||||
"genres": [],
|
||||
"title": (preview.get("title") if preview else None) or row.get("title") or base,
|
||||
"author": (preview.get("author") if preview else None) or row.get("author") or "Unknown",
|
||||
"shortDescription": (preview.get("description") if preview else None) or None,
|
||||
"genres": (preview.get("genres") if preview else None) or [],
|
||||
},
|
||||
"debug": {
|
||||
"sourcePathResolved": str(source_path) if source_path else None,
|
||||
"sourcePathExists": bool(source_path and source_path.exists()),
|
||||
"coverFound": bool(preview and preview.get("coverFound")),
|
||||
"coverExt": preview.get("coverExt") if preview else None,
|
||||
"titleFromEpub": preview.get("title") if preview else None,
|
||||
"authorFromEpub": preview.get("author") if preview else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/import/uploads/preview")
|
||||
async def upload_epub_and_preview(
|
||||
file: UploadFile = File(...),
|
||||
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:
|
||||
preview = _extract_epub_preview_payload(tmp_path)
|
||||
return {
|
||||
"suggested": {
|
||||
"title": preview.get("title"),
|
||||
"author": preview.get("author"),
|
||||
"shortDescription": preview.get("description") or None,
|
||||
"genres": preview.get("genres") or [],
|
||||
},
|
||||
"coverDetected": bool(preview.get("coverFound")),
|
||||
"coverPreviewDataUrl": preview.get("coverPreviewDataUrl"),
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/api/import/assets/{asset_id}/preview-cover")
|
||||
async def preview_source_asset_cover(
|
||||
asset_id: str,
|
||||
@@ -3741,23 +3811,20 @@ async def preview_source_asset_cover(
|
||||
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})
|
||||
await db.execute(text('SELECT id, path, sha256 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():
|
||||
source_path = _resolve_epub_source_path(str(row["path"]), str(row.get("sha256") or ""))
|
||||
if not source_path:
|
||||
raise HTTPException(status_code=400, detail="EPUB source file not found")
|
||||
cover = _extract_epub_cover(source_path)
|
||||
if not cover:
|
||||
preview = _extract_epub_preview_payload(source_path)
|
||||
cover_bytes = preview.get("coverBytes") if preview else None
|
||||
if not cover_bytes:
|
||||
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"
|
||||
ext = str(preview.get("coverExt") or ".jpg")
|
||||
media_type = _mime_from_extension(ext)
|
||||
return Response(content=cover_bytes, media_type=media_type)
|
||||
|
||||
|
||||
@@ -3862,8 +3929,8 @@ async def ai_suggest_source_asset(
|
||||
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():
|
||||
source_path = _resolve_epub_source_path(str(row["path"]))
|
||||
if not source_path or 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)
|
||||
@@ -3913,8 +3980,8 @@ async def parse_preview_source_asset(
|
||||
).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():
|
||||
source_path = _resolve_epub_source_path(str(row["path"]))
|
||||
if not source_path or 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)
|
||||
@@ -3951,8 +4018,8 @@ def _run_import_session_task(session_id: str) -> None:
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
source_path = Path(settings.epub_source_root) / str(row["path"])
|
||||
if not source_path.exists():
|
||||
source_path = _resolve_epub_source_path(str(row["path"]))
|
||||
if not source_path or 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"},
|
||||
@@ -4411,18 +4478,6 @@ async def upsert_source_asset(
|
||||
return dict(row) if row else {}
|
||||
|
||||
|
||||
@app.post("/api/import/discover")
|
||||
async def discover_epub_assets(
|
||||
limit: int = Query(default=200, ge=1, le=2000),
|
||||
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")
|
||||
|
||||
return await _discover_assets_incremental(limit=limit)
|
||||
|
||||
|
||||
@app.post("/api/import/assets/{asset_id}/approve")
|
||||
async def approve_source_asset(
|
||||
asset_id: str,
|
||||
|
||||
Generated
-286
@@ -51,7 +51,6 @@
|
||||
"input-otp": "1.4.2",
|
||||
"lucide-react": "^0.564.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"mongoose": "^9.2.4",
|
||||
"next": "16.1.6",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -93,82 +92,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/core": {
|
||||
"version": "0.34.3",
|
||||
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.3.tgz",
|
||||
"integrity": "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@panva/hkdf": "^1.1.1",
|
||||
"@types/cookie": "0.6.0",
|
||||
"cookie": "0.6.0",
|
||||
"jose": "^5.1.3",
|
||||
"oauth4webapi": "^2.10.4",
|
||||
"preact": "10.11.3",
|
||||
"preact-render-to-string": "5.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.2",
|
||||
"nodemailer": "^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@simplewebauthn/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@simplewebauthn/server": {
|
||||
"optional": true
|
||||
},
|
||||
"nodemailer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/core/node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/core/node_modules/jose": {
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
|
||||
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/core/node_modules/preact": {
|
||||
"version": "10.11.3",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
|
||||
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/core/node_modules/preact-render-to-string": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
|
||||
"integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pretty-format": "^3.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/prisma-adapter": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz",
|
||||
@@ -1584,14 +1507,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mongodb-js/saslprep": {
|
||||
"version": "1.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz",
|
||||
"integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==",
|
||||
"dependencies": {
|
||||
"sparse-bitfield": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
|
||||
@@ -4299,13 +4214,6 @@
|
||||
"tailwindcss": "4.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
@@ -4392,19 +4300,6 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webidl-conversions": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
|
||||
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="
|
||||
},
|
||||
"node_modules/@types/whatwg-url": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz",
|
||||
"integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==",
|
||||
"dependencies": {
|
||||
"@types/webidl-conversions": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vercel/analytics": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz",
|
||||
@@ -4593,14 +4488,6 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/bson": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz",
|
||||
"integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001786",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz",
|
||||
@@ -5190,14 +5077,6 @@
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/kareem": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz",
|
||||
"integrity": "sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/leac": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||
@@ -5545,109 +5424,6 @@
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
|
||||
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="
|
||||
},
|
||||
"node_modules/memory-pager": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
|
||||
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="
|
||||
},
|
||||
"node_modules/mongodb": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.1.tgz",
|
||||
"integrity": "sha512-067DXiMjcpYQl6bGjWQoTUEE9UoRViTtKFcoqX7z08I+iDZv/emH1g8XEFiO3qiDfXAheT5ozl1VffDTKhIW/w==",
|
||||
"dependencies": {
|
||||
"@mongodb-js/saslprep": "^1.3.0",
|
||||
"bson": "^7.1.1",
|
||||
"mongodb-connection-string-url": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@aws-sdk/credential-providers": "^3.806.0",
|
||||
"@mongodb-js/zstd": "^7.0.0",
|
||||
"gcp-metadata": "^7.0.1",
|
||||
"kerberos": "^7.0.0",
|
||||
"mongodb-client-encryption": ">=7.0.0 <7.1.0",
|
||||
"snappy": "^7.3.2",
|
||||
"socks": "^2.8.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@aws-sdk/credential-providers": {
|
||||
"optional": true
|
||||
},
|
||||
"@mongodb-js/zstd": {
|
||||
"optional": true
|
||||
},
|
||||
"gcp-metadata": {
|
||||
"optional": true
|
||||
},
|
||||
"kerberos": {
|
||||
"optional": true
|
||||
},
|
||||
"mongodb-client-encryption": {
|
||||
"optional": true
|
||||
},
|
||||
"snappy": {
|
||||
"optional": true
|
||||
},
|
||||
"socks": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/mongodb-connection-string-url": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz",
|
||||
"integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==",
|
||||
"dependencies": {
|
||||
"@types/whatwg-url": "^13.0.0",
|
||||
"whatwg-url": "^14.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose": {
|
||||
"version": "9.4.1",
|
||||
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.4.1.tgz",
|
||||
"integrity": "sha512-4rFBWa+/wdBQSfvnOPJBpiSG6UCEbhSQh865dEdaH9Y8WfHBUC+I2XT28dp0IBIGrEwmh+gzrgZgea5PbmrHWA==",
|
||||
"dependencies": {
|
||||
"kareem": "3.2.0",
|
||||
"mongodb": "~7.1",
|
||||
"mpath": "0.9.0",
|
||||
"mquery": "6.0.0",
|
||||
"ms": "2.1.3",
|
||||
"sift": "17.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mongoose"
|
||||
}
|
||||
},
|
||||
"node_modules/mpath": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
|
||||
"integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mquery": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz",
|
||||
"integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -5794,16 +5570,6 @@
|
||||
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA=="
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
"version": "2.17.0",
|
||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz",
|
||||
"integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -6122,14 +5888,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
@@ -6427,11 +6185,6 @@
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/sift": {
|
||||
"version": "17.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz",
|
||||
"integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ=="
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||
@@ -6449,14 +6202,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sparse-bitfield": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
|
||||
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
|
||||
"dependencies": {
|
||||
"memory-pager": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
@@ -6545,17 +6290,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
||||
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-toolbelt": {
|
||||
"version": "9.6.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz",
|
||||
@@ -6749,26 +6483,6 @@
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||
"dependencies": {
|
||||
"tr46": "^5.1.0",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import os
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
|
||||
def main() -> None:
|
||||
load_dotenv()
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
raise RuntimeError("DATABASE_URL missing")
|
||||
|
||||
parts = urlsplit(database_url)
|
||||
filtered_query = [(k, v) for (k, v) in parse_qsl(parts.query, keep_blank_values=True) if k.lower() != "schema"]
|
||||
normalized_url = urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(filtered_query), parts.fragment))
|
||||
|
||||
engine = create_engine(normalized_url)
|
||||
tables = [
|
||||
"ImportCandidateChapter",
|
||||
"AssetNovelMapping",
|
||||
"ImportJob",
|
||||
"ImportSession",
|
||||
"SourceAsset",
|
||||
]
|
||||
|
||||
with engine.begin() as conn:
|
||||
for table in tables:
|
||||
conn.execute(text(f'DROP TABLE IF EXISTS "{table}" CASCADE'))
|
||||
|
||||
print("Dropped legacy tables")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
import os
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
|
||||
def _normalize_database_url(raw_url: str) -> str:
|
||||
parts = urlsplit(raw_url)
|
||||
filtered_query = [(k, v) for (k, v) in parse_qsl(parts.query, keep_blank_values=True) if k.lower() != "schema"]
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(filtered_query), parts.fragment))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
load_dotenv()
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
raise RuntimeError("DATABASE_URL missing")
|
||||
|
||||
engine = create_engine(_normalize_database_url(database_url))
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text('DROP TABLE IF EXISTS "Series" CASCADE'))
|
||||
|
||||
print("Dropped Series table")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user