"""Upload preflight, batch, and verification API (US05-01, US05-02, US05-04). 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. ``verify`` and ``resolve`` are the way out of an uncertain outcome; ``start`` refuses one with ``409`` rather than letting the browser retry it. """ 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.upload_verification import ( UploadVerificationService, VerificationError, retry_blockers, ) 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 class ResolveRequest(BaseModel): asset_id: str # uploaded | upgraded | duplicate | skipped | failed | unknown outcome: str # What the operator actually checked, and who they are — both mandatory so a # manual resolution can never look like server evidence. evidence: str actor: 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 _verification(request: Request) -> UploadVerificationService: return UploadVerificationService( 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}") # An uncertain outcome or bytes changed after upload must be resolved first # (US05-04); the worker refuses them too, but the user is told here. blocked = retry_blockers(batch) if blocked: return _error(409, blocked[0]["code"], blocked[0]["message"]) 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}/verify") def verify_batch(batch_id: str, request: Request): """Check the batch against Immich and the bytes on disk (US05-04).""" try: return _verification(request).verify(batch_id) except VerificationError as error: return _error(404 if error.code == "not_found" else 422, error.code, str(error)) @router.post("/upload-batches/{batch_id}/resolve") def resolve_item(batch_id: str, body: ResolveRequest, request: Request): """Record an operator's own verification of one item. Evidence is mandatory.""" try: return _verification(request).resolve( batch_id, body.asset_id, outcome=body.outcome, evidence=body.evidence, actor=body.actor, ) except VerificationError as error: return _error(404 if error.code == "not_found" else 422, error.code, str(error)) @router.get("/upload-batches/{batch_id}/verifications") def list_verifications(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 {"verifications": _verification(request).history(batch_id)} @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))