"""FastAPI-App: REST-API + statisches Frontend.""" # SPDX-License-Identifier: Apache-2.0 from __future__ import annotations import logging from contextlib import asynccontextmanager from pathlib import Path from typing import Any from fastapi import Depends, FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from sqlalchemy.orm import Session from app import __version__, crud, schemas from app.database import get_db, init_db from app.external import lookup_book_by_isbn, lookup_book_by_title from app.seed import seed_if_empty logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) log = logging.getLogger("watchstack") BASE_DIR = Path(__file__).resolve().parent STATIC_DIR = BASE_DIR / "static" __all__ = ["app"] # ---------- Lifespan (init DB + seed on boot) ------------------------------- @asynccontextmanager async def lifespan(_: FastAPI): init_db() with next(get_db()) as db: seed_if_empty(db) log.info("WatchStack %s ready", __version__) yield app = FastAPI( title="WatchStack", description="Watchlist für Bücher, Serien & mehr.", version=__version__, lifespan=lifespan, contact={"name": "WatchStack", "url": "https://git.pkop.de/Vibecode/watchlist"}, license_info={"name": "Apache-2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0"}, ) # CORS — offen genug für lokale Beta, in Produktion via env einschränken app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") # ---------- UI --------------------------------------------------------------- @app.get("/", response_class=HTMLResponse, include_in_schema=False) def root() -> HTMLResponse: return HTMLResponse((STATIC_DIR / "index.html").read_text(encoding="utf-8")) # ---------- Health / Version ------------------------------------------------- @app.get("/api/health", tags=["meta"]) def health() -> dict[str, str]: return {"status": "ok", "service": "watchstack", "version": __version__} # ---------- Lookups ---------------------------------------------------------- @app.get("/api/lookup/book", tags=["lookup"]) def lookup_book( isbn: str | None = Query(None, description="ISBN-10 oder ISBN-13"), title: str | None = Query(None), ) -> dict[str, Any]: """Schlägt Buchdaten extern nach. ISBN hat Vorrang.""" if isbn: result = lookup_book_by_isbn(isbn) elif title: result = lookup_book_by_title(title) else: raise HTTPException(400, "isbn oder title erforderlich") if not result: raise HTTPException(404, "Keine Daten gefunden") return result # ---------- Sortierung ------------------------------------------------------- _SORT_KEYS = { "updated_desc", "updated_asc", "title_asc", "title_desc", "rating_desc", "rating_asc", "year_desc", "year_asc", } # ---------- Media ------------------------------------------------------------ def _get_or_404(db: Session, media_id: int): """Holt einen Eintrag oder wirft 404.""" m = crud.get_media(db, media_id) if not m: raise HTTPException(404, "Eintrag nicht gefunden") return m @app.get("/api/media", response_model=list[schemas.MediaOut], tags=["media"]) def api_list_media( kind: str | None = Query(None, pattern="^(book|series)$"), status: str | None = Query(None, pattern="^(plan|reading|done|hold|dropped)$"), search: str | None = Query(None, max_length=200), genre: str | None = Query(None, max_length=60), tag: str | None = Query(None, max_length=40), sort: str = Query("updated_desc"), db: Session = Depends(get_db), ): if sort not in _SORT_KEYS: raise HTTPException(400, f"sort muss eines von {sorted(_SORT_KEYS)} sein") return crud.list_media(db, kind=kind, status=status, search=search, genre=genre, tag=tag, sort=sort) @app.post("/api/media", response_model=schemas.MediaOut, status_code=201, tags=["media"]) def api_create_media(payload: schemas.MediaCreate, db: Session = Depends(get_db)): return crud.create_media(db, payload) @app.get("/api/media/{media_id}", response_model=schemas.MediaOut, tags=["media"]) def api_get_media(media_id: int, db: Session = Depends(get_db)): return _get_or_404(db, media_id) @app.patch("/api/media/{media_id}", response_model=schemas.MediaOut, tags=["media"]) def api_update_media( media_id: int, payload: schemas.MediaUpdate, db: Session = Depends(get_db), ): return crud.update_media(db, _get_or_404(db, media_id), payload) @app.delete("/api/media/{media_id}", status_code=204, tags=["media"]) def api_delete_media(media_id: int, db: Session = Depends(get_db)): crud.delete_media(db, _get_or_404(db, media_id)) return None @app.post("/api/media/{media_id}/progress", response_model=schemas.MediaOut, tags=["media"]) def api_progress( media_id: int, payload: schemas.ProgressUpdate, db: Session = Depends(get_db), ): return crud.bump_progress(db, _get_or_404(db, media_id), payload) # ---------- Taxonomien ------------------------------------------------------- @app.get("/api/genres", response_model=list[schemas.GenreOut], tags=["taxonomy"]) def api_genres(db: Session = Depends(get_db)): return crud.list_genres(db) @app.get("/api/tags", response_model=list[schemas.TagOut], tags=["taxonomy"]) def api_tags(db: Session = Depends(get_db)): return crud.list_tags(db) # ---------- Statistik -------------------------------------------------------- @app.get("/api/stats", response_model=schemas.StatsResponse, tags=["stats"]) def api_stats(db: Session = Depends(get_db)): return crud.stats(db)