Initial commit: WatchStack v0.1.0
- 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
This commit is contained in:
+153
@@ -0,0 +1,153 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user