chore: beta release v0.5.0-beta
CI / test (push) Has been cancelled
CI / docker (push) Has been cancelled

- 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
This commit is contained in:
ki
2026-07-21 23:34:01 +02:00
parent 56a8d5d302
commit 0bca2eaaa2
19 changed files with 736 additions and 124 deletions
+76 -46
View File
@@ -1,65 +1,87 @@
"""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 Optional
from typing import Any
from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
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 crud, schemas
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")
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"
app = FastAPI(
title="WatchStack",
description="Watchlist für Bücher, Serien & mehr.",
version="0.1.0",
)
__all__ = ["app"]
@app.on_event("startup")
def _startup() -> None:
# ---------- 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
# ---------- Statische Dateien & UI ------------------------------------------
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(request: Request) -> HTMLResponse:
index = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
return HTMLResponse(index)
def root() -> HTMLResponse:
return HTMLResponse((STATIC_DIR / "index.html").read_text(encoding="utf-8"))
# ---------- Health -----------------------------------------------------------
# ---------- Health / Version -------------------------------------------------
@app.get("/api/health", tags=["meta"])
def health() -> dict:
return {"status": "ok", "service": "watchstack", "version": app.version}
def health() -> dict[str, str]:
return {"status": "ok", "service": "watchstack", "version": __version__}
# ---------- Metadaten-Lookup -------------------------------------------------
# ---------- Lookups ----------------------------------------------------------
@app.get("/api/lookup/book", tags=["lookup"])
def lookup_book(
isbn: Optional[str] = Query(None, description="ISBN-10 oder ISBN-13"),
title: Optional[str] = Query(None),
) -> dict:
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)
@@ -72,20 +94,40 @@ def lookup_book(
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: Optional[str] = Query(None, pattern="^(book|series)$"),
status: Optional[str] = Query(None, pattern="^(plan|reading|done|hold|dropped)$"),
search: Optional[str] = Query(None),
genre: Optional[str] = Query(None),
tag: Optional[str] = Query(None),
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),
):
items = crud.list_media(db, kind=kind, status=status, search=search, genre=genre, tag=tag, sort=sort)
return items
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"])
@@ -95,10 +137,7 @@ def api_create_media(payload: schemas.MediaCreate, db: Session = Depends(get_db)
@app.get("/api/media/{media_id}", response_model=schemas.MediaOut, tags=["media"])
def api_get_media(media_id: int, db: Session = Depends(get_db)):
m = crud.get_media(db, media_id)
if not m:
raise HTTPException(404, "Eintrag nicht gefunden")
return m
return _get_or_404(db, media_id)
@app.patch("/api/media/{media_id}", response_model=schemas.MediaOut, tags=["media"])
@@ -107,18 +146,12 @@ def api_update_media(
payload: schemas.MediaUpdate,
db: Session = Depends(get_db),
):
m = crud.get_media(db, media_id)
if not m:
raise HTTPException(404, "Eintrag nicht gefunden")
return crud.update_media(db, m, payload)
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)):
m = crud.get_media(db, media_id)
if not m:
raise HTTPException(404, "Eintrag nicht gefunden")
crud.delete_media(db, m)
crud.delete_media(db, _get_or_404(db, media_id))
return None
@@ -128,10 +161,7 @@ def api_progress(
payload: schemas.ProgressUpdate,
db: Session = Depends(get_db),
):
m = crud.get_media(db, media_id)
if not m:
raise HTTPException(404, "Eintrag nicht gefunden")
return crud.bump_progress(db, m, payload)
return crud.bump_progress(db, _get_or_404(db, media_id), payload)
# ---------- Taxonomien -------------------------------------------------------