"""UploadVerificationService — resolving uncertain uploads (US05-04). US05-03 leaves a batch honest but sometimes uncertain: a killed uploader, an unpinned report grammar, or a file the report never mentioned all end as ``unknown``. Retrying such a batch is the dangerous move — Immich may already hold the files, and a blind retry is how a lost response turns into a second server asset. So this service resolves uncertainty *before* anything is re-run: - **evidence beats the report.** Verification asks Immich itself whether it holds the exact SHA-1 the batch recorded before uploading. That answer is authoritative over the uploader's text, in both directions: present makes an unknown item ``uploaded``, absent makes it ``failed`` and therefore safe to retry. - **no answer is never "no".** An unreachable server, missing credentials, or a response this adapter will not interpret leave the item ``inconclusive``. The batch stays ``unknown_requires_verification`` and stays un-runnable. - **changed bytes are a stale warning, not a silent re-upload.** Verification re-hashes what is on disk. A file edited after its upload is flagged, the batch is marked ``stale_bytes``, and re-running it is refused: uploading again would create or upgrade a server asset the user never approved (concept §8). - **manual resolution is evidence, not permission.** An operator may record what they checked in Immich, but only with a non-empty note and their identity, and every decision is appended to an immutable history alongside the server answers. Retry policy lives in :func:`retry_blockers`, which :class:`UploadBatchService` enforces before every attempt and the API surfaces as ``409``. """ from __future__ import annotations import uuid from datetime import datetime, timezone from pathlib import Path from sqlalchemy import select from sqlalchemy.orm import sessionmaker from photo_pipeline.config import Config from photo_pipeline.integrations import immich_go from photo_pipeline.integrations import immich_go_report as report_parser from photo_pipeline.models import UploadBatch, UploadItem, UploadVerification from photo_pipeline.services.hashing import sha256_file from photo_pipeline.services.upload_reports import REQUIRES_VERIFICATION, VERIFIED PRESENT = "present" ABSENT = "absent" INCONCLUSIVE = "inconclusive" MANUAL = "manual" MAX_EVIDENCE_CHARS = 500 def _now() -> datetime: return datetime.now(timezone.utc) class VerificationError(RuntimeError): """The request cannot be carried out (unknown batch/item, missing evidence).""" def __init__(self, code: str, message: str) -> None: super().__init__(message) self.code = code class UploadVerificationService: def __init__(self, session_factory: sessionmaker, *, config: Config) -> None: self._session_factory = session_factory self._config = config # ── verification ────────────────────────────────────────────────────────── def verify(self, batch_id: str) -> dict: """Check every item of a batch against the server and the bytes on disk. Returns ``{"batch_id", "state", "outcome_state", "stale_bytes", "server_reachable", "detail", "counts", "items"}``. Safe to call repeatedly: the same evidence produces the same rows, and each run appends its own history entries. """ with self._session_factory() as session: batch = session.get(UploadBatch, batch_id) if batch is None: raise VerificationError("not_found", f"unknown upload batch {batch_id!r}") items = list( session.scalars( select(UploadItem) .where(UploadItem.batch_id == batch_id) .order_by(UploadItem.path) ) ) answer = immich_go.bulk_upload_check( self._config.immich_server_url, self._config.immich_api_key.get_secret_value() if self._config.immich_api_key else None, {item.asset_id: item.sha1 for item in items if item.sha1}, ) detail = answer["detail"] for item in items: observed, changed = _current_bytes(item) held = answer["present"].get(item.asset_id) if item.sha1 is None: result, evidence = INCONCLUSIVE, "no upload hash was recorded for this file" elif held is True: result, evidence = PRESENT, f"Immich holds sha1 {item.sha1}" elif held is False: result, evidence = ABSENT, f"Immich does not hold sha1 {item.sha1}" else: result = INCONCLUSIVE evidence = detail or "the server did not classify these bytes" item.verification = result item.verified_at = _now() item.observed_sha256 = observed item.changed_after_upload = changed # The server is authoritative over the report text — but only when # it actually answered. if result == PRESENT: item.outcome = report_parser.UPLOADED item.evidence = evidence item.outcome_at = _now() elif result == ABSENT: item.outcome = report_parser.FAILED item.evidence = evidence item.outcome_at = _now() session.add( _event( batch_id, item.asset_id, action="verify", source="immich_api", result=result, outcome=item.outcome, evidence=evidence, ) ) _resolve_batch(batch, items) session.commit() return _report(batch, items, server_reachable=answer["reachable"], detail=detail) # ── manual resolution ───────────────────────────────────────────────────── def resolve( self, batch_id: str, asset_id: str, *, outcome: str, evidence: str, actor: str ) -> dict: """Record an operator's own verification of one item. ``evidence`` and ``actor`` are mandatory: a manual resolution is only worth keeping if it says what was checked and who checked it. """ if outcome not in report_parser.OUTCOMES: raise VerificationError("invalid_outcome", f"unknown upload outcome {outcome!r}") evidence = (evidence or "").strip() actor = (actor or "").strip() if not evidence: raise VerificationError("evidence_required", "a manual resolution must record evidence") if not actor: raise VerificationError("actor_required", "a manual resolution must record its author") with self._session_factory() as session: batch = session.get(UploadBatch, batch_id) if batch is None: raise VerificationError("not_found", f"unknown upload batch {batch_id!r}") item = session.get(UploadItem, {"batch_id": batch_id, "asset_id": asset_id}) if item is None: raise VerificationError("not_found", f"{asset_id!r} is not part of this batch") item.outcome = outcome item.evidence = evidence[:MAX_EVIDENCE_CHARS] item.outcome_at = _now() item.verification = MANUAL item.verified_at = _now() session.add( _event( batch_id, asset_id, action="resolve", source="operator", result=MANUAL, outcome=outcome, evidence=evidence, actor=actor, ) ) items = list( session.scalars( select(UploadItem) .where(UploadItem.batch_id == batch_id) .order_by(UploadItem.path) ) ) _resolve_batch(batch, items) session.commit() return _report(batch, items, server_reachable=None, detail=None) # ── history ─────────────────────────────────────────────────────────────── def history(self, batch_id: str) -> list[dict]: """Every verification and resolution recorded for this batch, oldest first.""" with self._session_factory() as session: rows = session.scalars( select(UploadVerification) .where(UploadVerification.batch_id == batch_id) .order_by(UploadVerification.created_at, UploadVerification.id) ) return [ { "id": row.id, "asset_id": row.asset_id, "action": row.action, "source": row.source, "result": row.result, "outcome": row.outcome, "evidence": row.evidence, "actor": row.actor, "created_at": row.created_at.isoformat() if row.created_at else None, } for row in rows ] # ── retry policy ───────────────────────────────────────────────────────────── def retry_blockers(batch: dict) -> list[dict]: """Why this batch may not be (re)run, in the order the user should fix them. A plain uploader failure is a *safe* failure: nothing uncertain happened, so it is retryable. An uncertain outcome and edited bytes are not. """ from photo_pipeline.services.upload_batches import BatchState, RUNNABLE_STATES blockers: list[dict] = [] if batch["state"] == BatchState.UNKNOWN: blockers.append( { "code": "requires_verification", "message": "this upload's outcome is uncertain; verify it before retrying", } ) if batch.get("stale_bytes"): blockers.append( { "code": "changed_after_upload", "message": "files in this batch changed after they were uploaded; " "re-approve them through a fresh preflight", } ) if not blockers and batch["state"] not in RUNNABLE_STATES: blockers.append({"code": "not_runnable", "message": f"batch is {batch['state']}"}) return blockers # ── internals ──────────────────────────────────────────────────────────────── def _current_bytes(item: UploadItem) -> tuple[str | None, bool]: """``(hash on disk now, changed since upload)``. A missing file counts as changed.""" path = Path(item.path) if not path.exists(): return None, item.sha256 is not None observed = sha256_file(path) return observed, bool(item.sha256 and observed != item.sha256) def _event( batch_id: str, asset_id: str, *, action: str, source: str, result: str, outcome: str | None, evidence: str, actor: str | None = None, ) -> UploadVerification: return UploadVerification( id=str(uuid.uuid4()), batch_id=batch_id, asset_id=asset_id, action=action, source=source, result=result, outcome=outcome, evidence=evidence[:MAX_EVIDENCE_CHARS], actor=actor, created_at=_now(), ) def _resolve_batch(batch: UploadBatch, items: list[UploadItem]) -> None: """Fold the item evidence back into the batch's own state. An uncertain batch only leaves that state once every item is accounted for: all present makes it succeeded, any absent makes it a safe failure to retry, and a single inconclusive item keeps it uncertain. """ from photo_pipeline.services.upload_batches import BatchState batch.stale_bytes = any(item.changed_after_upload for item in items) batch.verified_at = _now() unresolved = [item for item in items if item.outcome in (None, report_parser.UNKNOWN)] batch.outcome_state = VERIFIED if items and not unresolved else REQUIRES_VERIFICATION if batch.state != BatchState.UNKNOWN or unresolved: return if any(item.outcome == report_parser.FAILED for item in items): batch.state = BatchState.FAILED batch.error_code = "verified_incomplete" batch.error_message = "verification proved some files never reached Immich" else: batch.state = BatchState.SUCCEEDED batch.error_code = batch.error_message = None def _report( batch: UploadBatch, items: list[UploadItem], *, server_reachable: bool | None, detail: str | None, ) -> dict: counts: dict[str, int] = {} for item in items: key = item.verification or "unverified" counts[key] = counts.get(key, 0) + 1 return { "batch_id": batch.id, "state": batch.state, "outcome_state": batch.outcome_state, "stale_bytes": batch.stale_bytes, "server_reachable": server_reachable, "detail": detail, "counts": counts, "items": [ { "asset_id": item.asset_id, "path": item.path, "verification": item.verification, "outcome": item.outcome, "evidence": item.evidence, "changed_after_upload": item.changed_after_upload, "verified_at": item.verified_at.isoformat() if item.verified_at else None, } for item in items ], }