chore: remove legacy tables and series table scripts
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:
2026-05-04 19:43:22 +07:00
parent 1b1217ace2
commit 212d4df42f
6 changed files with 402 additions and 570 deletions
+2
View File
@@ -6,6 +6,7 @@ __pycache__/
.venv/ .venv/
dist/ dist/
build/ build/
node_modules/
.DS_Store .DS_Store
.env .env
@@ -17,3 +18,4 @@ test_*.py
data/epub-source/* data/epub-source/*
data/content/* data/content/*
data/nas-content/* data/nas-content/*
node_modules
-2
View File
@@ -28,8 +28,6 @@ class Settings(BaseSettings):
deepseek_model: str = "deepseek-chat" deepseek_model: str = "deepseek-chat"
openrouter_key: str = "" openrouter_key: str = ""
openrouter_paused: str = "true" openrouter_paused: str = "true"
import_scan_interval_minutes: int = 30
import_scan_limit: int = 2000
@property @property
def google_client_id_list(self) -> list[str]: def google_client_id_list(self) -> list[str]:
+337 -282
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import base64
import datetime as dt import datetime as dt
import hashlib import hashlib
import json import json
@@ -10,6 +11,8 @@ import re
import secrets import secrets
import tempfile import tempfile
import uuid import uuid
import zipfile
import xml.etree.ElementTree as ET
from difflib import SequenceMatcher from difflib import SequenceMatcher
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -34,18 +37,9 @@ from app.storage import storage
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
global _SCAN_TASK
if str(settings.auto_schema_bootstrap).lower() in {"1", "true", "yes", "on"}: if str(settings.auto_schema_bootstrap).lower() in {"1", "true", "yes", "on"}:
await _ensure_migration_tables() 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 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: async def _ensure_migration_tables() -> None:
@@ -191,7 +185,18 @@ async def _ensure_migration_tables() -> None:
app = FastAPI(title=settings.app_name, lifespan=lifespan) 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() _IMPORT_TASKS: set[asyncio.Task[Any]] = set()
@@ -202,87 +207,6 @@ def _normalized_search_name(value: str) -> str:
return _norm_title(stem) 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origin_list, allow_origins=settings.cors_origin_list,
@@ -1017,32 +941,10 @@ async def _resolve_series_id(
series_id: str | None, series_id: str | None,
series_name: str | None, series_name: str | None,
) -> str | None: ) -> str | None:
_ = db
_ = series_name
sid = str(series_id or "").strip() sid = str(series_id or "").strip()
if sid: return sid or None
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: 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) 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") @app.get("/api/mod/truyen")
async def mod_list_novels( async def mod_list_novels(
@@ -1230,8 +1032,8 @@ async def mod_list_novels(
await db.execute( await db.execute(
text( text(
'SELECT n.id, n.title, n.slug, n."authorName", n.status, n."totalChapters", n."coverUrl", ' '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 ' '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" ' 'FROM "Novel" n '
'ORDER BY n."updatedAt" DESC, n."createdAt" DESC' 'ORDER BY n."updatedAt" DESC, n."createdAt" DESC'
) )
) )
@@ -1268,8 +1070,8 @@ async def mod_get_novel_detail(
text( text(
'SELECT n.id, n.title, n.slug, n."authorName", n."originalTitle", n."originalAuthorName", ' 'SELECT n.id, n.title, n.slug, n."authorName", n."originalTitle", n."originalAuthorName", '
'n.description, n."coverUrl", n.status, n."totalChapters", ' 'n.description, n."coverUrl", n.status, n."totalChapters", '
'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 = :id LIMIT 1' 'FROM "Novel" n WHERE n.id = :id LIMIT 1'
), ),
{"id": novel_id}, {"id": novel_id},
) )
@@ -1483,15 +1285,15 @@ async def mod_list_missing_novels(
where_parts.append(f"({' OR '.join(filters)})") where_parts.append(f"({' OR '.join(filters)})")
if q.strip(): if q.strip():
params["q"] = f"%{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 "" where_sql = f"WHERE {' AND '.join(where_parts)}" if where_parts else ""
rows = ( rows = (
await db.execute( await db.execute(
text( text(
'SELECT n.id, n.title, n.slug, n."authorName", n."coverUrl", n.description, n."totalChapters", n."updatedAt", ' '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 ' '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" ' 'FROM "Novel" n '
f'{where_sql} ' f'{where_sql} '
'ORDER BY n."updatedAt" DESC, n.title ASC LIMIT 2000' '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() 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() 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() 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 { return {
"novelCount": int(novel_count or 0), "novelCount": int(novel_count or 0),
"totalViews": int(total_views 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" mode = "regex" if (splitMode or "").lower() == "regex" else "toc"
pattern = (chapterRegex or "").strip() or None pattern = (chapterRegex or "").strip() or None
chapters = _epub_extract_with_mode(tmp_path, mode, pattern) chapters = _epub_extract_with_mode(tmp_path, mode, pattern)
base_title = " ".join((title or Path(file.filename or "novel").stem).split()).strip() or "Untitled" epub_meta = _extract_epub_metadata(tmp_path)
base_author = " ".join((authorName or "Unknown").split()).strip() or "Unknown" inferred_title = str(epub_meta.get("title") or Path(file.filename or "novel").stem)
base_desc = (description or "").strip() inferred_author = str(epub_meta.get("author") or "Unknown")
has_cover = bool(_extract_epub_cover(tmp_path)) 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": if str(preview or "").lower() == "true":
return { return {
@@ -2204,6 +2021,7 @@ async def mod_epub_upload(
"splitMode": mode, "splitMode": mode,
"detectedStructureType": "standard", "detectedStructureType": "standard",
"hasCoverFromEpub": has_cover, "hasCoverFromEpub": has_cover,
"coverPreviewDataUrl": cover_preview_data_url,
"parserInfo": { "parserInfo": {
"splitMode": mode, "splitMode": mode,
"chapterRegexUsed": pattern, "chapterRegexUsed": pattern,
@@ -2218,7 +2036,7 @@ async def mod_epub_upload(
"title": base_title, "title": base_title,
"authorName": base_author, "authorName": base_author,
"description": base_desc, "description": base_desc,
"detectedGenres": [], "detectedGenres": inferred_genres,
"totalChapters": len(chapters), "totalChapters": len(chapters),
}, },
"chaptersPreview": [ "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 "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('DELETE FROM "ChapterMeta" WHERE "novelId" = :novel_id'), {"novel_id": novel_id})
await db.execute( 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, "id": novel_id,
"author": base_author, "author": base_author,
"desc": base_desc, "desc": base_desc,
"cover": None, "cover": uploaded_cover_url,
"series_id": target_series_id, "series_id": target_series_id,
}, },
) )
@@ -2329,7 +2147,7 @@ async def mod_epub_upload(
"slug": slug, "slug": slug,
"author": base_author, "author": base_author,
"desc": base_desc, "desc": base_desc,
"cover": None, "cover": uploaded_cover_url,
"status": "Đang ra", "status": "Đang ra",
"series_id": target_series_id, "series_id": target_series_id,
}, },
@@ -2418,7 +2236,7 @@ async def browse_novels(
params["q"] = f"%{q.strip()}%" params["q"] = f"%{q.strip()}%"
where_parts.append( where_parts.append(
'(n.title ILIKE :q OR n."originalTitle" ILIKE :q OR n."authorName" ILIKE :q ' '(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 "" where_sql = f"WHERE {' AND '.join(where_parts)}" if where_parts else ""
@@ -2426,11 +2244,10 @@ async def browse_novels(
base_select = ( base_select = (
'n.id, n.title, n.slug, n."originalTitle", n."authorName", n."coverUrl", n."coverColor", ' '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.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 = ( base_from = (
'FROM "Novel" n ' 'FROM "Novel" n '
'LEFT JOIN "Series" s ON s.id = n."seriesId" '
) )
if collapse_series: 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", ' '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.description, n."coverUrl", n."coverColor", n.status, n."totalChapters", n.views, n.rating, '
'n."ratingCount", n."bookmarkCount", n."seriesId", n."createdAt", n."updatedAt", ' '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 ' 'FROM "Novel" n '
'LEFT JOIN "Series" s ON s.id = n."seriesId" '
'WHERE n.id = :value OR n.slug = :value ' 'WHERE n.id = :value OR n.slug = :value '
'LIMIT 1' 'LIMIT 1'
), ),
@@ -2761,10 +2577,9 @@ async def suggest_novels(q: str = "", db: AsyncSession = Depends(get_db_session)
rows = ( rows = (
await db.execute( await db.execute(
text( 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 ' 'FROM "Novel" n '
'LEFT JOIN "Series" s ON s.id = n."seriesId" ' 'WHERE n.title ILIKE :q OR n."authorName" ILIKE :q '
'WHERE n.title ILIKE :q OR n."authorName" ILIKE :q OR s.name ILIKE :q '
'ORDER BY n.views DESC, n."updatedAt" DESC ' 'ORDER BY n.views DESC, n."updatedAt" DESC '
'LIMIT 8' 'LIMIT 8'
), ),
@@ -2874,12 +2689,6 @@ class SourceAssetAiSuggestPayload(BaseModel):
chapterStartPattern: str | None = None chapterStartPattern: str | None = None
class ModSeriesPayload(BaseModel):
id: str | None = None
name: str
description: str | None = None
class ModNovelPayload(BaseModel): class ModNovelPayload(BaseModel):
id: str | None = None id: str | None = None
title: str | None = None title: str | None = None
@@ -3308,6 +3117,21 @@ def _extract_epub_cover(epub_path: Path) -> tuple[bytes, str] | None:
except Exception: except Exception:
return None 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(): for item in book.get_items():
try: try:
media_type = str(getattr(item, "media_type", "") or "") 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 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: def _upload_cover_bytes_to_r2(image_bytes: bytes, extension: str, *, key_prefix: str) -> str | None:
if not image_bytes: if not image_bytes:
return None return None
@@ -3719,19 +3745,63 @@ async def preview_source_asset_metadata(
path = str(row["path"]) path = str(row["path"])
base = path.split("/")[-1].rsplit(".", 1)[0] base = path.split("/")[-1].rsplit(".", 1)[0]
source_path = Path(settings.epub_source_root) / path source_path = _resolve_epub_source_path(path, str(row.get("sha256") or ""))
cover_detected = bool(_extract_epub_cover(source_path)) if source_path.exists() else False preview = _extract_epub_preview_payload(source_path) if source_path else None
return { return {
"asset": {**dict(row), "coverDetected": cover_detected}, "asset": {**dict(row), "coverDetected": bool(preview and preview.get("coverFound"))},
"suggested": { "suggested": {
"title": row.get("title") or base, "title": (preview.get("title") if preview else None) or row.get("title") or base,
"author": row.get("author") or "Unknown", "author": (preview.get("author") if preview else None) or row.get("author") or "Unknown",
"shortDescription": None, "shortDescription": (preview.get("description") if preview else None) or None,
"genres": [], "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") @app.get("/api/import/assets/{asset_id}/preview-cover")
async def preview_source_asset_cover( async def preview_source_asset_cover(
asset_id: str, asset_id: str,
@@ -3741,23 +3811,20 @@ async def preview_source_asset_cover(
if user.get("role") not in ("MOD", "ADMIN"): if user.get("role") not in ("MOD", "ADMIN"):
raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Forbidden")
row = ( 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() ).mappings().first()
if not row: if not row:
raise HTTPException(status_code=404, detail="Source asset not found") raise HTTPException(status_code=404, detail="Source asset not found")
source_path = Path(settings.epub_source_root) / str(row["path"]) source_path = _resolve_epub_source_path(str(row["path"]), str(row.get("sha256") or ""))
if not source_path.exists(): if not source_path:
raise HTTPException(status_code=400, detail="EPUB source file not found") raise HTTPException(status_code=400, detail="EPUB source file not found")
cover = _extract_epub_cover(source_path) preview = _extract_epub_preview_payload(source_path)
if not cover: cover_bytes = preview.get("coverBytes") if preview else None
if not cover_bytes:
raise HTTPException(status_code=404, detail="Cover not found in EPUB") raise HTTPException(status_code=404, detail="Cover not found in EPUB")
cover_bytes, ext = cover ext = str(preview.get("coverExt") or ".jpg")
media_type = "image/jpeg" media_type = _mime_from_extension(ext)
if ext == ".png":
media_type = "image/png"
elif ext == ".webp":
media_type = "image/webp"
return Response(content=cover_bytes, media_type=media_type) return Response(content=cover_bytes, media_type=media_type)
@@ -3862,8 +3929,8 @@ async def ai_suggest_source_asset(
if not row: if not row:
raise HTTPException(status_code=404, detail="Source asset not found") raise HTTPException(status_code=404, detail="Source asset not found")
source_path = Path(settings.epub_source_root) / str(row["path"]) source_path = _resolve_epub_source_path(str(row["path"]))
if not source_path.exists(): if not source_path or not source_path.exists():
raise HTTPException(status_code=400, detail="EPUB source file not found") raise HTTPException(status_code=400, detail="EPUB source file not found")
chapters = _epub_extract_with_mode(source_path, payload.splitMode, payload.chapterStartPattern) chapters = _epub_extract_with_mode(source_path, payload.splitMode, payload.chapterStartPattern)
@@ -3913,8 +3980,8 @@ async def parse_preview_source_asset(
).mappings().first() ).mappings().first()
if not row: if not row:
raise HTTPException(status_code=404, detail="Source asset not found") raise HTTPException(status_code=404, detail="Source asset not found")
source_path = Path(settings.epub_source_root) / str(row["path"]) source_path = _resolve_epub_source_path(str(row["path"]))
if not source_path.exists(): if not source_path or not source_path.exists():
raise HTTPException(status_code=400, detail="EPUB source file not found") raise HTTPException(status_code=400, detail="EPUB source file not found")
chapters = _epub_extract_with_mode(source_path, payload.splitMode, payload.chapterStartPattern) 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() await db.commit()
source_path = Path(settings.epub_source_root) / str(row["path"]) source_path = _resolve_epub_source_path(str(row["path"]))
if not source_path.exists(): if not source_path or not source_path.exists():
await db.execute( await db.execute(
text('UPDATE "ImportSession" SET status = :st, phase = :ph, log = :log, "updatedAt" = NOW() WHERE id = :id'), 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"}, {"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 {} 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") @app.post("/api/import/assets/{asset_id}/approve")
async def approve_source_asset( async def approve_source_asset(
asset_id: str, asset_id: str,
-286
View File
@@ -51,7 +51,6 @@
"input-otp": "1.4.2", "input-otp": "1.4.2",
"lucide-react": "^0.564.0", "lucide-react": "^0.564.0",
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"mongoose": "^9.2.4",
"next": "16.1.6", "next": "16.1.6",
"next-auth": "^4.24.13", "next-auth": "^4.24.13",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -93,82 +92,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/@auth/prisma-adapter": {
"version": "2.11.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz", "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz",
@@ -1584,14 +1507,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@next/env": {
"version": "16.1.6", "version": "16.1.6",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
@@ -4299,13 +4214,6 @@
"tailwindcss": "4.2.2" "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": { "node_modules/@types/d3-array": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -4392,19 +4300,6 @@
"@types/react": "^19.2.0" "@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": { "node_modules/@vercel/analytics": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz", "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": "^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": { "node_modules/caniuse-lite": {
"version": "1.0.30001786", "version": "1.0.30001786",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz",
@@ -5190,14 +5077,6 @@
"setimmediate": "^1.0.5" "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": { "node_modules/leac": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", "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", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==" "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": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "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", "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "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", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" "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": { "node_modules/react": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -6427,11 +6185,6 @@
"@img/sharp-win32-x64": "0.34.5" "@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": { "node_modules/sonner": {
"version": "1.7.4", "version": "1.7.4",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
@@ -6449,14 +6202,6 @@
"node": ">=0.10.0" "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": { "node_modules/split2": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "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", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" "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": { "node_modules/ts-toolbelt": {
"version": "9.6.0", "version": "9.6.0",
"resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz",
@@ -6749,26 +6483,6 @@
"d3-timer": "^3.0.1" "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": { "node_modules/xml2js": {
"version": "0.6.2", "version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
+35
View File
@@ -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()
+28
View File
@@ -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()