diff --git a/.gitignore b/.gitignore index 1389ee3..f0580c2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/config.py b/app/config.py index 62a3430..675c710 100644 --- a/app/config.py +++ b/app/config.py @@ -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]: diff --git a/app/main.py b/app/main.py index 4c7db3c..dfed3b3 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/package-lock.json b/package-lock.json index 424545c..7ecf915 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/scripts/drop_legacy_import_tables.py b/scripts/drop_legacy_import_tables.py new file mode 100644 index 0000000..4a670a8 --- /dev/null +++ b/scripts/drop_legacy_import_tables.py @@ -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() diff --git a/scripts/drop_series_table.py b/scripts/drop_series_table.py new file mode 100644 index 0000000..cbaa264 --- /dev/null +++ b/scripts/drop_series_table.py @@ -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()