Files
watchlist/quivio/main.py
T
ki 9a4909f083
CI / test (push) Has been cancelled
chore(rebrand): rename WatchStack -> Quivio
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)
2026-07-22 00:42:09 +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 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)