import os import glob import logging import threading import tempfile import asyncio from contextlib import asynccontextmanager from fastapi import FastAPI, Request, Query, HTTPException, UploadFile, File, Form from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from datetime import datetime import database import watcher logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") SNAPSHOTS_DIR = os.environ.get("SNAPSHOTS_DIR", "/data/snapshots") EVENTS_DIR = os.environ.get("EVENTS_DIR", "/data/events") os.makedirs(SNAPSHOTS_DIR, exist_ok=True) os.makedirs(EVENTS_DIR, exist_ok=True) @asynccontextmanager async def lifespan(app: FastAPI): database.init_db() t = threading.Thread(target=watcher.run_watcher, daemon=True) t.start() yield app = FastAPI(lifespan=lifespan) app.mount("/snapshots", StaticFiles(directory=SNAPSHOTS_DIR), name="snapshots") app.mount("/events", StaticFiles(directory=EVENTS_DIR), name="events") templates = Jinja2Templates(directory="/app/templates") def ts_to_str(ts): try: return datetime.fromtimestamp(ts).strftime("%d/%m/%Y %H:%M:%S") except Exception: return str(ts) @app.get("/", response_class=HTMLResponse) async def index( request: Request, page: int = Query(1, ge=1), plate: str = Query(""), camera: str = Query(""), date: str = Query(""), ): limit = 20 offset = (page - 1) * limit events = database.get_events(limit=limit, offset=offset, plate_filter=plate or None, camera_filter=camera or None, date_filter=date or None) total = database.count_events(plate_filter=plate or None, camera_filter=camera or None, date_filter=date or None) cameras = database.get_cameras() total_pages = max(1, (total + limit - 1) // limit) for ev in events: ev["time_str"] = ts_to_str(ev["start_time"]) return templates.TemplateResponse("index.html", { "request": request, "events": events, "total": total, "page": page, "total_pages": total_pages, "cameras": cameras, "filter_plate": plate, "filter_camera": camera, "filter_date": date, }) CAPTURE_DURATION = int(os.environ.get("CAPTURE_DURATION", "15")) CAPTURE_FPS = int(os.environ.get("CAPTURE_FPS", "5")) @app.get("/event/{event_id}", response_class=HTMLResponse) async def event_detail(request: Request, event_id: str): ev = database.get_event(event_id) if not ev: raise HTTPException(status_code=404, detail="Événement introuvable") ev["time_str"] = ts_to_str(ev["start_time"]) event_dir = os.path.join(EVENTS_DIR, event_id) frame_paths = sorted(glob.glob(os.path.join(event_dir, "frame_*.jpg"))) frames = [f"events/{event_id}/{os.path.basename(p)}" for p in frame_paths] clip_abs = os.path.join("/data", ev["clip_path"]) if ev.get("clip_path") else None has_clip = bool(clip_abs and os.path.exists(clip_abs)) clip_size = "" if has_clip: size = os.path.getsize(clip_abs) clip_size = f"{size // 1024 // 1024}MB" if size > 1024 * 1024 else f"{size // 1024}KB" plate_crop_abs = os.path.join(event_dir, "plate_crop.jpg") plate_crop = f"events/{event_id}/plate_crop.jpg" if os.path.exists(plate_crop_abs) else None return templates.TemplateResponse("event_detail.html", { "request": request, "ev": ev, "frames": frames, "has_clip": has_clip, "clip_size": clip_size, "plate_crop": plate_crop, "capture_duration": CAPTURE_DURATION, "capture_fps": CAPTURE_FPS, "capture_total": CAPTURE_DURATION * CAPTURE_FPS, }) @app.get("/api/events") async def api_events( page: int = Query(1, ge=1), plate: str = Query(""), camera: str = Query(""), date: str = Query(""), ): limit = 20 offset = (page - 1) * limit events = database.get_events(limit=limit, offset=offset, plate_filter=plate or None, camera_filter=camera or None, date_filter=date or None) total = database.count_events(plate_filter=plate or None, camera_filter=camera or None, date_filter=date or None) for ev in events: ev["time_str"] = ts_to_str(ev["start_time"]) return {"events": events, "total": total} @app.post("/event/{event_id}/plate") async def update_plate(event_id: str, plate: str = Form("")): ev = database.get_event(event_id) if not ev: raise HTTPException(status_code=404, detail="Événement introuvable") database.update_plate(event_id, plate) return RedirectResponse(f"/event/{event_id}", status_code=303) @app.post("/upload") async def upload_clip(file: UploadFile = File(...)): if not file.filename.lower().endswith(".mp4"): raise HTTPException(status_code=400, detail="Seuls les fichiers .mp4 sont acceptés") with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp: content = await file.read() tmp.write(content) tmp_path = tmp.name try: loop = asyncio.get_event_loop() event_id = await loop.run_in_executor(None, watcher.process_uploaded_clip, tmp_path) finally: os.unlink(tmp_path) return RedirectResponse(f"/event/{event_id}", status_code=303) @app.get("/health") async def health(): return {"status": "ok"}