199 lines
6.2 KiB
Python
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
|