177 lines
6.3 KiB
Python
177 lines
6.3 KiB
Python
"""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))
|