114 lines
4.0 KiB
Python
114 lines
4.0 KiB
Python
"""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. 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
|
|
|
|
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"])
|
|
|
|
|
|
class PreflightRequest(BaseModel):
|
|
# ``None`` means every album; an explicit list scopes the check.
|
|
albums: list[str] | None = None
|
|
# Approving a partial upload is an explicit act, never a default.
|
|
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 _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))
|