bookmark/bookmark/api/routes.py
pyr0ball 2b4875d77c fix: handle empty-platform captures, no-op fires, and observe races
- IntakeSession.observe_frame now catches ValueError from crop_to_book
  (empty-platform capture), cleans up any orphan wide-image file/dir,
  resets the strategy, and does not advance session state. The error
  is surfaced via a new last_error field.
- /api/observe adds an "error" field to the response when a capture
  fires but fails, instead of an unhandled 500.
- observe_frame only reports fired=True (and only runs capture logic)
  when state is WAITING_FRONT or WAITING_BACK, so a completed book
  left on the platform can no longer produce a no-op "fired" capture
  from an auto-strategy re-triggering.
- observe_frame now holds a per-session threading.Lock for its whole
  body, since the frontend polls /api/observe every ~500ms without
  chaining requests and Catalogue.update_row() does a non-atomic
  read-all-then-rewrite-whole-file rewrite of catalogue.csv.

Adds tests for empty-platform front/back captures and the
BOOK_COMPLETE no-op-fire case, plus an API-level empty-platform test.
2026-07-13 11:22:08 -07:00

54 lines
1.8 KiB
Python

import cv2
from fastapi import APIRouter, Request, Response
from fastapi.responses import JSONResponse
router = APIRouter()
@router.get("/api/session-state")
def get_session_state(request: Request):
session = request.app.state.session
return {"state": session.state.value, "book_id": session.current_book_id}
@router.post("/api/observe")
def observe(request: Request):
session = request.app.state.session
camera = request.app.state.camera
try:
frame = camera.read_frame()
except RuntimeError as exc:
return JSONResponse(status_code=503, content={"error": "Camera unavailable", "detail": str(exc)})
request.app.state.latest_frame = frame
fired = session.observe_frame(frame)
response = {"fired": fired, "state": session.state.value, "book_id": session.current_book_id}
if session.last_error:
response["error"] = session.last_error
return response
@router.post("/api/manual-trigger")
def manual_trigger(request: Request):
session = request.app.state.session
if not hasattr(session.strategy, "trigger"):
return Response(status_code=400, content="Active strategy does not support manual trigger")
session.strategy.trigger()
return {"ok": True}
@router.post("/api/next-book")
def next_book(request: Request):
session = request.app.state.session
session.start_next_book()
return {"state": session.state.value}
@router.get("/api/preview.jpg")
def preview(request: Request):
frame = request.app.state.latest_frame
if frame is None:
return Response(status_code=404, content="No frame captured yet")
ok, buffer = cv2.imencode(".jpg", frame)
if not ok:
return Response(status_code=500, content="Failed to encode preview frame")
return Response(content=buffer.tobytes(), media_type="image/jpeg")