Spaces:
Building
Building
| """ | |
| Read-only API for the frontend. Wraps db list_entries, get_segments, get_chapter_segments. | |
| Run from backend: uvicorn api:app --reload --host 0.0.0.0 --port 8000 | |
| """ | |
| from fastapi import APIRouter, FastAPI, HTTPException, Query, Response | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import JSONResponse | |
| import proxy | |
| from services import mangadex_service | |
| from services.image_processor import ImageProcessor | |
| import httpx | |
| import db | |
| from sqlalchemy.exc import IntegrityError | |
| from sqlmodel import Session, text | |
| from db.models import Manga | |
| from auth_supabase import CurrentAuthContext, CurrentUserId | |
| from db.schemas import ( | |
| ChapterListOut, | |
| ReadingListAddIn, | |
| ReadingListCollectionCreateIn, | |
| ReadingListCollectionOut, | |
| ReadingListCollectionRenameIn, | |
| ReadingListItemOut, | |
| SegmentListOut, | |
| UserDisplayNamePatchIn, | |
| ) | |
| # app = FastAPI( | |
| # title="Manga Translator API", | |
| # description="Read endpoints for chapters and segments", | |
| # version="1.0.0", | |
| # ) | |
| # app.add_middleware( | |
| # CORSMiddleware, | |
| # allow_origins=["*"], # Currently allow all origins, should be restricted to specific origins in production | |
| # allow_credentials=True, | |
| # allow_methods=["*"], | |
| # allow_headers=["*"], | |
| # ) | |
| router = APIRouter() | |
| def root(): | |
| """API root - confirms the API is running.""" | |
| return { | |
| "message": "Manga Translator API is working", | |
| "docs": "/docs", | |
| "redoc": "/redoc", | |
| "health": "/health", | |
| } | |
| def health_db(): | |
| engine = db.get_engine() | |
| try: | |
| with Session(engine) as session: | |
| session.exec(text("SELECT 1")) | |
| return {"status": "ok"} | |
| except Exception as e: | |
| return {"status": "error", "detail": str(e)} | |
| def list_mangas( | |
| order_by: str = Query("created_at", description="manga_title | created_at | updated_at"), | |
| order_desc: bool = Query(True, description="Sort descending"), | |
| limit: int | None = Query(None, ge=1, le=500, description="Max results (default: all)"), | |
| offset: int = Query(0, ge=0, description="Skip N results"), | |
| ): | |
| """List mangas (manga_title, created_at, updated_at). Supports pagination.""" | |
| return db.list_mangas(order_by=order_by, order_desc=order_desc, limit=limit, offset=offset) | |
| def list_chapters( | |
| manga_title: str = Query(...), | |
| provider_id: str | None = Query(None, description="e.g. local, mangadex"), | |
| limit: int | None = Query(None, ge=1, le=500, description="Max results (default: all)"), | |
| offset: int = Query(0, ge=0, description="Skip N results"), | |
| ): | |
| """List chapters (id, chapter_number, created_at, updated_at). Filters by manga_title; optionally by provider_id.""" | |
| return db.list_chapters(manga_title, provider_id, limit=limit, offset=offset) | |
| def get_segments( | |
| provider_id: str | None = Query(None, description="e.g. local, mangadex"), | |
| manga_title: str | None = Query(None), | |
| chapter_number: float | None = Query(None), | |
| page_number: int | None = Query(None), | |
| limit: int | None = Query(None, ge=1, le=1000, description="Max results (default: all)"), | |
| offset: int = Query(0, ge=0, description="Skip N results"), | |
| ): | |
| """Get segments with optional filters. Supports pagination.""" | |
| return db.get_segments( | |
| provider_id=provider_id, | |
| manga_title=manga_title, | |
| chapter_number=chapter_number, | |
| page_number=page_number, | |
| limit=limit, | |
| offset=offset, | |
| ) | |
| def get_chapter_segments( | |
| provider_id: str = Query(..., description="e.g. local, mangadex"), | |
| manga_title: str = Query(...), | |
| chapter_number: float = Query(...), | |
| limit: int | None = Query(None, ge=1, le=1000, description="Max results (default: all)"), | |
| offset: int = Query(0, ge=0, description="Skip N results"), | |
| ): | |
| """Get all segments for one chapter. Supports pagination.""" | |
| return db.get_chapter_segments(provider_id, manga_title, chapter_number, limit=limit, offset=offset) | |
| # to make sure api is running and responding | |
| def health(): | |
| """Health check.""" | |
| return {"status": "ok"} | |
| def get_reading_lists(user_id: CurrentUserId): | |
| """List named reading lists for the signed-in user (with manga counts).""" | |
| return db.list_reading_list_collections_with_counts(user_id) | |
| def post_reading_list_collection( | |
| ctx: CurrentAuthContext, body: ReadingListCollectionCreateIn | |
| ): | |
| """Create a new named reading list.""" | |
| try: | |
| col = db.create_reading_list_collection( | |
| ctx.user_id, body.name, user_email=ctx.email | |
| ) | |
| except ValueError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) from e | |
| except IntegrityError as e: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Could not create reading list (database constraint).", | |
| ) from e | |
| if col.id is None: | |
| raise HTTPException(status_code=500, detail="Reading list id missing after save") | |
| return ReadingListCollectionOut( | |
| id=col.id, | |
| name=col.name, | |
| manga_count=0, | |
| created_at=col.created_at, | |
| updated_at=col.updated_at, | |
| latest_external_manga_id=None, | |
| ) | |
| def patch_reading_list_collection( | |
| user_id: CurrentUserId, | |
| reading_list_id: int, | |
| body: ReadingListCollectionRenameIn, | |
| ): | |
| """Rename a reading list.""" | |
| try: | |
| col = db.update_reading_list_collection(user_id, reading_list_id, body.name) | |
| except ValueError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) from e | |
| if col is None: | |
| raise HTTPException(status_code=404, detail="Reading list not found") | |
| counts = db.list_reading_list_collections_with_counts(user_id) | |
| for c in counts: | |
| if c.id == col.id: | |
| return c | |
| return ReadingListCollectionOut( | |
| id=col.id, | |
| name=col.name, | |
| manga_count=0, | |
| created_at=col.created_at, | |
| updated_at=col.updated_at, | |
| latest_external_manga_id=None, | |
| ) | |
| def delete_reading_list_collection(user_id: CurrentUserId, reading_list_id: int): | |
| """Delete a named list and all manga entries in it.""" | |
| ok = db.delete_reading_list_collection(user_id, reading_list_id) | |
| if not ok: | |
| raise HTTPException(status_code=404, detail="Reading list not found") | |
| return {"ok": True} | |
| def get_reading_list_items( | |
| user_id: CurrentUserId, | |
| reading_list_id: int, | |
| limit: int = Query(100, ge=1, le=500), | |
| offset: int = Query(0, ge=0), | |
| ): | |
| """Manga rows for one named list.""" | |
| if db.get_reading_list_collection(user_id, reading_list_id) is None: | |
| raise HTTPException(status_code=404, detail="Reading list not found") | |
| return db.list_reading_list_items_with_manga( | |
| user_id, reading_list_id, limit=limit, offset=offset | |
| ) | |
| def post_reading_list_item( | |
| user_id: CurrentUserId, reading_list_id: int, body: ReadingListAddIn | |
| ): | |
| """Add or update a manga on a named list (by provider catalog id).""" | |
| if db.get_reading_list_collection(user_id, reading_list_id) is None: | |
| raise HTTPException(status_code=404, detail="Reading list not found") | |
| try: | |
| row = db.add_reading_list_item_by_source( | |
| user_id, | |
| reading_list_id, | |
| body.provider_id, | |
| body.external_manga_id, | |
| body.manga_title, | |
| last_chapter_number=body.last_chapter_number, | |
| ) | |
| except ValueError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) from e | |
| items = db.list_reading_list_items_with_manga(user_id, reading_list_id, limit=500) | |
| for it in items: | |
| if it.id == row.id or it.manga_id == row.manga_id: | |
| return it | |
| raise HTTPException(status_code=500, detail="Item not found after save") | |
| def delete_reading_list_item( | |
| user_id: CurrentUserId, reading_list_id: int, manga_id: int | |
| ): | |
| """Remove one manga from a named list.""" | |
| ok = db.remove_reading_list_item_from_list(user_id, reading_list_id, manga_id) | |
| if not ok: | |
| raise HTTPException(status_code=404, detail="Not on this reading list") | |
| return {"ok": True} | |
| def patch_me_display_name(ctx: CurrentAuthContext, body: UserDisplayNamePatchIn): | |
| """Update display_name on public.users for the signed-in user.""" | |
| try: | |
| db.update_app_user_display_name( | |
| ctx.user_id, | |
| body.display_name, | |
| email=ctx.email, | |
| ) | |
| except ValueError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) from e | |
| return Response(status_code=204) | |
| ########### | |
| ########### | |
| ########### | |
| async def proxy_manga_page(chapter_id: str, page_index: int): | |
| urls = mangadex_service.get_chapter_panel_urls(chapter_id) | |
| if not urls or page_index >= len(urls): | |
| return {"error": "Page not found"}, 404 | |
| return await proxy.get_manga_page_stream(urls[page_index]) | |
| def get_chapter_page_urls(chapter_id: str): | |
| """MangaDex at-home CDN URLs for every page in a chapter (for client readers).""" | |
| urls = mangadex_service.get_chapter_panel_urls(chapter_id) | |
| if not urls: | |
| raise HTTPException(status_code=404, detail="No pages for this chapter") | |
| return {"urls": urls} | |
| async def proxy_manga_cover_art(manga_id: str, file_name: str, size: int = 256): | |
| url = f"https://uploads.mangadex.org/covers/{manga_id}/{file_name}.{size}.jpg" | |
| print(url) | |
| return await proxy.get_manga_page_stream(url) | |
| async def get_manga_cover_json(manga_id: str): | |
| """MangaDex manga UUID → 256px cover URL for list UIs (no DB storage).""" | |
| cover_url = await mangadex_service.get_manga_cover_url_256(manga_id) | |
| return {"cover_url": cover_url} | |
| async def get_manga_info_json(manga_id: str): | |
| """MangaDex title + synopsis for reading-list rows (no DB storage).""" | |
| info = await mangadex_service.get_manga_public_info(manga_id) | |
| return info | |
| async def get_popular_manga( | |
| title: str = "", | |
| limit: int = 15, | |
| offset: int = 0, | |
| order_by: str = "followedCount", | |
| order_direction: str = "desc", | |
| cover_art: bool = True | |
| ): | |
| results = await mangadex_service.search_manga( | |
| title=title, | |
| limit=limit, | |
| offset=offset, | |
| order_by=order_by, | |
| order_direction=order_direction, | |
| cover_art=cover_art | |
| ) | |
| return results | |
| async def get_chapters( | |
| manga_id: str, | |
| limit: int = 100, | |
| translatedLanguage: list[str] = Query(None), | |
| offset: int = 0, | |
| order_by: str = "chapter", | |
| order_direction: str = "desc", | |
| content_rating: list[str] = Query(["safe", "suggestive"]), #, "erotica", "pornographic"], #oh hell naw | |
| includeEmptyPages: int = 0 | |
| ): | |
| results = await mangadex_service.get_manga_chapters( | |
| manga_id=manga_id, | |
| limit=limit, | |
| languages=translatedLanguage, | |
| offset=offset, | |
| order_by=order_by, | |
| order_direction=order_direction, | |
| content_rating=content_rating, | |
| include_empty=includeEmptyPages | |
| ) | |
| return results | |
| # Standalone ASGI app for `uvicorn api:app` (no ML / translation stack from main.py). | |
| app = FastAPI( | |
| title="Manga Translator API", | |
| description="Read endpoints for chapters and segments", | |
| version="1.0.0", | |
| ) | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| app.include_router(router) | |
| def _value_error_handler(request, exc: ValueError): | |
| return JSONResponse(status_code=400, content={"detail": str(exc)}) |