"""admin/routers/books.py — Book management routes. Routes: GET /{slug}/books DELETE /{slug}/books/{book_id} POST /{slug}/books/{book_id}/cover POST /{slug}/books/import-url/preview — Fetch metadata preview from URL POST /{slug}/books/import-url — Create book from URL metadata """ from typing import Annotated from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import ( get_db, get_current_author_scoped, get_redis, validate_book_id, ) from app.schemas.admin import ( BookActiveRequest, BookReorderRequest, ImportURLConfirmRequest, ImportURLRequest, PlatformListingUpsertRequest, ) from app.services.book_service import BookService router = APIRouter() BookId = Annotated[str, Depends(validate_book_id)] def _validate_url(url: str) -> str: """Strip, validate scheme, and block SSRF targets (R-306).""" from app.services.ssrf_guard import UnsafeUrlError, assert_url_safe_for_fetch url = url.strip() if not url.startswith(("http://", "https://")): raise HTTPException(400, "Please provide a full URL including https://") try: return assert_url_safe_for_fetch(url) except UnsafeUrlError: raise HTTPException(400, "This URL cannot be imported for security reasons") @router.get("/{author_slug}/books") async def list_books( author_slug: str, current_user=Depends(get_current_author_scoped), db: AsyncSession = Depends(get_db), ): """List all books for the author.""" return await BookService(db).list_books(current_user.id) @router.get("/{author_slug}/books/{book_id}/platform-presence") async def get_book_platform_presence( author_slug: str, book_id: BookId, current_user=Depends(get_current_author_scoped), db: AsyncSession = Depends(get_db), redis=Depends(get_redis), ): """Return stored platform presence scan for a book.""" result = await BookService(db).get_platform_presence( current_user.id, book_id, redis=redis, ) if result is None: raise HTTPException(404, "Book not found") return result @router.put("/{author_slug}/books/{book_id}/platforms/{platform_id}") async def upsert_book_platform_listing( author_slug: str, book_id: BookId, platform_id: str, body: PlatformListingUpsertRequest, current_user=Depends(get_current_author_scoped), db: AsyncSession = Depends(get_db), ): """Create or update one per-platform listing (URL, ISBN/ASIN, format prices).""" fields = body.model_dump(exclude_unset=True) result = await BookService(db).upsert_platform_listing( current_user.id, book_id, platform_id, fields, ) if result is None: raise HTTPException(404, "Book not found") return result @router.delete("/{author_slug}/books/{book_id}") async def delete_book( author_slug: str, book_id: BookId, current_user=Depends(get_current_author_scoped), db: AsyncSession = Depends(get_db), ): """Delete a book and its ChromaDB collection.""" result = await BookService(db).delete_book(current_user.id, book_id) if result is None: raise HTTPException(404, "Book not found") return result @router.patch("/{author_slug}/books/{book_id}/active") async def set_book_active( author_slug: str, book_id: BookId, body: BookActiveRequest, current_user=Depends(get_current_author_scoped), db: AsyncSession = Depends(get_db), ): """Activate or deactivate a book (inactive books are excluded from RAG).""" result = await BookService(db).set_book_active(current_user.id, book_id, body.is_active) if result is None: raise HTTPException(404, "Book not found") return result @router.patch("/{author_slug}/books/reorder") async def reorder_books( author_slug: str, body: BookReorderRequest, current_user=Depends(get_current_author_scoped), db: AsyncSession = Depends(get_db), ): """Update display order for widget book list.""" return await BookService(db).reorder_books(current_user.id, body.ordered_ids) @router.post("/{author_slug}/books/{book_id}/reindex") async def reindex_book( author_slug: str, book_id: BookId, current_user=Depends(get_current_author_scoped), db: AsyncSession = Depends(get_db), redis=Depends(get_redis), ): """Re-run indexing for a failed or stuck book.""" result = await BookService(db).reindex_book(current_user.id, book_id, redis) if result is None: raise HTTPException(404, "Book not found") return result @router.post("/{author_slug}/books/{book_id}/cover") async def upload_book_cover( author_slug: str, book_id: BookId, current_user=Depends(get_current_author_scoped), db: AsyncSession = Depends(get_db), file: UploadFile = File(...), ): """Upload a book cover image (JPG/PNG/WebP, max 5MB).""" result = await BookService(db).upload_cover(current_user.id, book_id, file) if result is None: raise HTTPException(404, "Book not found") return result @router.post("/{author_slug}/books/import-url/preview") async def preview_book_url( author_slug: str, body: ImportURLRequest, current_user=Depends(get_current_author_scoped), db: AsyncSession = Depends(get_db), redis=Depends(get_redis), ): """Fetch and preview book metadata from a supported platform URL.""" url = _validate_url(body.url) return await BookService.preview_url_import( url, redis, force_refresh=body.force_refresh, db=db, ) @router.post("/{author_slug}/books/import-url", status_code=201) async def import_book_from_url( author_slug: str, body: ImportURLConfirmRequest, current_user=Depends(get_current_author_scoped), db: AsyncSession = Depends(get_db), redis=Depends(get_redis), ): """Create a book record from a supported platform URL and index it immediately.""" url = _validate_url(body.url) return await BookService(db).import_from_url( author_id=current_user.id, url=url, title_override=body.title, author_override=body.author, description_override=body.description, cover_url_override=body.cover_url, isbn_override=body.isbn, genre_override=body.genre, price_override=body.price, price_hardcover=body.price_hardcover, price_ebook=body.price_ebook, price_audiobook=body.price_audiobook, redis=redis, )