Files
watchlist/app/main.py
T
ki 0bca2eaaa2
CI / test (push) Has been cancelled
CI / docker (push) Has been cancelled
chore: beta release v0.5.0-beta
- Bump version to 0.5.0-beta
- Add Apache-2.0 LICENSE (official text) + NOTICE
- Add Docker support: multi-stage Dockerfile + docker-compose
  * non-root user, tini PID 1, gunicorn + uvicorn workers
  * healthcheck, OCI labels, persistent /data volume
- Add GitHub Actions CI (lint/import-check + Docker smoke-test)
- Backend polish:
  * modern lifespan handler (replaces deprecated on_event)
  * explicit sort validation (400 instead of 500)
  * title min_length=1 schema validation
  * configurable data dir + http timeout via env
  * avg_rating helper extracted (DRY)
- Frontend polish:
  * beta badge in title, footer with live version
  * loading spinner on initial fetch
  * updated README, badges, config table, roadmap
2026-07-21 23:34:01 +02:00

184 lines
5.8 KiB
Python

"""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)