US05-02: Orchestrate Album Upload Batches (#72)

This commit was merged in pull request #72.
This commit is contained in:
2026-08-16 13:06:37 +02:00
parent dcbd492a0d
commit e4ae5da440
14 changed files with 1446 additions and 14 deletions

View File

@@ -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))