CI / test (push) Has been cancelled
Quivio (Kofferwort aus Quire + Video) ersetzt den Arbeitstitel WatchStack. Es ist origineller, frei auf PyPI/npm, und passt perfekt zur Doppelnatur der App: Buecher (Quire) + Serien (Video). Geaendert: - Python-Paket app/ -> quivio/ (+ alle Imports angepasst) - DB-File watchstack.db -> quivio.db - Docker-Image: quivio:0.5.1-beta, Container-Name quivio - Compose-Services: quivio, quivio-local - ENV-Vars: QUIVIO_VERSION, QUIVIO_PORT, Volume quivio-data - Frontend-Logo "W" -> "Q", Titel "Quivio", Footer - Favicon "Q" (Georgia serif fuer klassischen Look) - Logger-Name: watchstack -> quivio - README, NOTICE, dist/README, data/README: Quivio - CI-Workflow: testet quivio/ Pfade - build-and-push.sh: lokaler Tag "quivio:VERSION" - service-Feld in /api/health: quivio - OpenAPI title: Quivio Bugfix (gefunden beim Renaming): - seed.py hatte versteckten "from app.database import DB_PATH" — gefixt Verifiziert end-to-end: - Python-Import OK - Backend: service=quivio, version=0.5.1-beta, 8 unique items - Docker-Build OK, Push in Gitea-Registry OK (neuer sha256-Digest) - Container: Quivio 0.5.1-beta ready (logger), HTML ohne WatchStack-Rest - OpenAPI title=Quivio - 8 unique items nach cold start (Race-Fix haelt)
184 lines
5.8 KiB
Python
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 quivio import __version__, crud, schemas
|
|
from quivio.database import get_db, init_db
|
|
from quivio.external import lookup_book_by_isbn, lookup_book_by_title
|
|
from quivio.seed import seed_if_empty
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
log = logging.getLogger("quivio")
|
|
|
|
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("Quivio %s ready", __version__)
|
|
yield
|
|
|
|
|
|
app = FastAPI(
|
|
title="Quivio",
|
|
description="Watchlist für Bücher, Serien & mehr. Quire + Video — dein Lese- und Schau-Tracker.",
|
|
version=__version__,
|
|
lifespan=lifespan,
|
|
contact={"name": "Quivio", "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": "quivio", "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)
|