import asyncio import logging import mimetypes import shutil from contextlib import asynccontextmanager from datetime import date from pathlib import Path from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%H:%M:%S", ) log = logging.getLogger("tafa.main") from config import AppConfig, load_config, save_config from session import Session, SessionStatus STAGING_BASE = Path.home() / ".tafa" / "staging" @asynccontextmanager async def lifespan(app: FastAPI): yield if STAGING_BASE.exists(): shutil.rmtree(STAGING_BASE, ignore_errors=True) log.info("Staging cleaned up on shutdown") app = FastAPI(lifespan=lifespan) app.state.session: Session | None = None app.mount("/static", StaticFiles(directory="static"), name="static") @app.get("/") async def index(): return FileResponse("static/index.html") # ── Config ──────────────────────────────────────────────────────────────────── class ConfigRequest(BaseModel): sources: list[str] date_start: str date_end: str destination: str @app.get("/api/config") async def get_config(): cfg = load_config() if cfg is None: return {"configured": False} return { "configured": True, "sources": cfg.sources, "date_start": cfg.date_start, "date_end": cfg.date_end, "destination": cfg.destination, } @app.post("/api/config") async def set_config(req: ConfigRequest): sources = [s.strip() for s in req.sources if s.strip()] if not sources: raise HTTPException(422, "At least one source path is required") try: d1 = date.fromisoformat(req.date_start) d2 = date.fromisoformat(req.date_end) except ValueError: raise HTTPException(422, "Dates must be in YYYY-MM-DD format") if d1 > d2: raise HTTPException(422, "date_start must be on or before date_end") if not req.destination.strip(): raise HTTPException(422, "destination is required") save_config(AppConfig( sources=sources, date_start=req.date_start, date_end=req.date_end, destination=req.destination.strip(), )) return {"ok": True} # ── Session ─────────────────────────────────────────────────────────────────── @app.post("/api/session/start") async def start_session(): if app.state.session and app.state.session.status == SessionStatus.DOWNLOADING: raise HTTPException(409, "A download is already in progress") cfg = load_config() if cfg is None: raise HTTPException(400, "No configuration saved. Set up config first.") try: d1 = date.fromisoformat(cfg.date_start) d2 = date.fromisoformat(cfg.date_end) except ValueError: raise HTTPException(400, "Saved config has invalid dates") if app.state.session: app.state.session.cleanup() session = Session( staging_base=STAGING_BASE, destination=Path(cfg.destination), ) app.state.session = session asyncio.create_task(session.run_download(cfg.sources, d1, d2)) return {"session_id": session.id, "status": "downloading"} @app.get("/api/session/status") async def session_status(): session = app.state.session if session is None: raise HTTPException(404, "No active session") return { "status": session.status, "error_message": session.error_message, "progress": session.download_progress, } @app.delete("/api/session") async def delete_session(): session = app.state.session if session: session.cleanup() app.state.session = None return {"ok": True} # ── Photo ───────────────────────────────────────────────────────────────────── @app.get("/api/photo/current") async def get_current_photo(): session = app.state.session if session is None or session.status != SessionStatus.READY: raise HTTPException(404, "No active ready session") return session.photo_metadata() _EMPTY_GIF = b"GIF89a\x01\x00\x01\x00\x00\xff\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x00;" @app.get("/api/photo/image") async def get_photo_image(offset: int = 0): session = app.state.session if session is None or session.status != SessionStatus.READY: raise HTTPException(404, "No active ready session") if offset == 0: result = session.current_photo() file_path = result[1] if result else None else: file_path = session.photo_at_offset(offset) if file_path is None or not file_path.exists(): # Thumbnail not yet downloaded — return transparent 1x1 gif instead of 404 return Response(content=_EMPTY_GIF, media_type="image/gif") media_type = mimetypes.guess_type(file_path.name)[0] or "image/jpeg" return FileResponse(str(file_path), media_type=media_type) class ActionRequest(BaseModel): action: str @app.post("/api/photo/action") async def photo_action(req: ActionRequest): session = app.state.session if session is None or session.status != SessionStatus.READY: raise HTTPException(409, "No active ready session") if session._processing: raise HTTPException(409, "Action already in progress") if req.action not in ("accept", "ignore", "remind"): raise HTTPException(422, f"Unknown action: {req.action}") session._processing = True try: if req.action == "accept": session.action_accept() elif req.action == "ignore": session.action_ignore() else: session.action_remind() return {"ok": True, "next": session.photo_metadata()} finally: session._processing = False