US05-02: Orchestrate Album Upload Batches (#72)
This commit was merged in pull request #72.
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
"""Upload preflight API (US05-01).
|
||||
"""Upload preflight and batch API (US05-01, US05-02).
|
||||
|
||||
Preflight is a command, not a resource read: it contacts the Immich server, hashes
|
||||
the current bytes, and issues a token. It uploads nothing — starting a batch is
|
||||
US05-02, so there is deliberately no start endpoint here.
|
||||
the current bytes, and issues a token. Creating a batch requires that token, and
|
||||
starting one enqueues a durable job on the single ``upload`` lane — the API never
|
||||
runs the uploader in the request thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,6 +12,13 @@ from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from photo_pipeline.jobs.domain_handlers import UPLOAD_BATCH, UPLOAD_LOCK
|
||||
from photo_pipeline.services.jobs import JobBlocked, JobService
|
||||
from photo_pipeline.services.upload_batches import (
|
||||
BatchConflict,
|
||||
BatchError,
|
||||
UploadBatchService,
|
||||
)
|
||||
from photo_pipeline.services.uploads import UploadError, UploadService
|
||||
|
||||
router = APIRouter(tags=["uploads"])
|
||||
@@ -23,17 +31,83 @@ class PreflightRequest(BaseModel):
|
||||
allow_partial: bool = False
|
||||
|
||||
|
||||
class CreateBatchRequest(PreflightRequest):
|
||||
# The token of the preflight the user approved; a stale one is refused.
|
||||
token: str
|
||||
|
||||
|
||||
def _service(request: Request) -> UploadService:
|
||||
return UploadService(request.app.state.session_factory, config=request.app.state.config)
|
||||
|
||||
|
||||
def _batches(request: Request) -> UploadBatchService:
|
||||
return UploadBatchService(request.app.state.session_factory, config=request.app.state.config)
|
||||
|
||||
|
||||
def _error(status: int, code: str, message: str) -> JSONResponse:
|
||||
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}})
|
||||
|
||||
|
||||
@router.post("/upload-preflight")
|
||||
def preflight(request: Request, body: PreflightRequest | None = None):
|
||||
body = body or PreflightRequest()
|
||||
try:
|
||||
return _service(request).preflight(body.albums, allow_partial=body.allow_partial)
|
||||
except UploadError as error:
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"error": {"code": "unknown_album", "message": str(error)}},
|
||||
return _error(422, "unknown_album", str(error))
|
||||
|
||||
|
||||
@router.post("/upload-batches", status_code=201)
|
||||
def create_batches(body: CreateBatchRequest, request: Request):
|
||||
"""Turn an approved preflight into one durable batch per album."""
|
||||
try:
|
||||
return {"batches": _batches(request).create(
|
||||
body.albums, token=body.token, allow_partial=body.allow_partial
|
||||
)}
|
||||
except UploadError as error:
|
||||
return _error(422, "unknown_album", str(error))
|
||||
except BatchConflict as error:
|
||||
return _error(409, error.code, str(error))
|
||||
except BatchError as error:
|
||||
return _error(422, error.code, str(error))
|
||||
|
||||
|
||||
@router.get("/upload-batches")
|
||||
def list_batches(request: Request) -> dict:
|
||||
return {"batches": _batches(request).list()}
|
||||
|
||||
|
||||
@router.get("/upload-batches/{batch_id}")
|
||||
def get_batch(batch_id: str, request: Request):
|
||||
batch = _batches(request).get(batch_id)
|
||||
if batch is None:
|
||||
return _error(404, "not_found", f"unknown upload batch {batch_id}")
|
||||
return batch
|
||||
|
||||
|
||||
@router.post("/upload-batches/{batch_id}/start")
|
||||
def start_batch(batch_id: str, request: Request):
|
||||
"""Queue the batch on the uploader lane. The worker performs the upload."""
|
||||
service = _batches(request)
|
||||
batch = service.get(batch_id)
|
||||
if batch is None:
|
||||
return _error(404, "not_found", f"unknown upload batch {batch_id}")
|
||||
try:
|
||||
job = JobService(request.app.state.session_factory).enqueue(
|
||||
UPLOAD_BATCH,
|
||||
lock=UPLOAD_LOCK,
|
||||
# One queued attempt per batch attempt: a double-clicked start reuses it.
|
||||
idempotency_key=f"upload:{batch_id}:{batch['attempt_count']}",
|
||||
items=[batch_id],
|
||||
)
|
||||
except JobBlocked as error:
|
||||
return _error(409, error.code, str(error))
|
||||
return {"batch_id": batch_id, "job": job}
|
||||
|
||||
|
||||
@router.post("/upload-batches/{batch_id}/cancel")
|
||||
def cancel_batch(batch_id: str, request: Request):
|
||||
try:
|
||||
return _batches(request).cancel(batch_id)
|
||||
except BatchError as error:
|
||||
return _error(404 if error.code == "not_found" else 409, error.code, str(error))
|
||||
|
||||
Reference in New Issue
Block a user