Initial commit
App works locally. No intention to bring this live.
This commit is contained in:
198
main.py
Normal file
198
main.py
Normal file
@@ -0,0 +1,198 @@
|
||||
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
|
||||
Reference in New Issue
Block a user