Files
Tafa/main.py
Philipp Ludewig de3ab5d2a6 Initial commit
App works locally. No intention to bring this live.
2026-04-23 16:35:12 -04:00

199 lines
6.2 KiB
Python

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