- FastAPI + SQLite backend with REST API - Polymorphic Media model (book + series) - Cover lookup via Open Library - Vanilla HTML/CSS/JS frontend (MAL-inspired) - Dark theme, status tabs, drawer detail, stats modal - 8 seed demo entries
154 lines
4.9 KiB
Python
154 lines
4.9 KiB
Python
"""FastAPI-App: REST-API + statisches Frontend."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import 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"
|
|
|
|
app = FastAPI(
|
|
title="WatchStack",
|
|
description="Watchlist für Bücher, Serien & mehr.",
|
|
version="0.1.0",
|
|
)
|
|
|
|
|
|
@app.on_event("startup")
|
|
def _startup() -> None:
|
|
init_db()
|
|
with next(get_db()) as db:
|
|
seed_if_empty(db)
|
|
|
|
|
|
# ---------- Statische Dateien & UI ------------------------------------------
|
|
|
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
|
|
|
|
@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)
|
|
|
|
|
|
# ---------- Health -----------------------------------------------------------
|
|
|
|
@app.get("/api/health", tags=["meta"])
|
|
def health() -> dict:
|
|
return {"status": "ok", "service": "watchstack", "version": app.version}
|
|
|
|
|
|
# ---------- Metadaten-Lookup -------------------------------------------------
|
|
|
|
@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:
|
|
"""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
|
|
|
|
|
|
# ---------- Media ------------------------------------------------------------
|
|
|
|
@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),
|
|
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
|
|
|
|
|
|
@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)):
|
|
m = crud.get_media(db, media_id)
|
|
if not m:
|
|
raise HTTPException(404, "Eintrag nicht gefunden")
|
|
return m
|
|
|
|
|
|
@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),
|
|
):
|
|
m = crud.get_media(db, media_id)
|
|
if not m:
|
|
raise HTTPException(404, "Eintrag nicht gefunden")
|
|
return crud.update_media(db, m, 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)
|
|
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),
|
|
):
|
|
m = crud.get_media(db, media_id)
|
|
if not m:
|
|
raise HTTPException(404, "Eintrag nicht gefunden")
|
|
return crud.bump_progress(db, m, 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)
|