Files
photoanalyzer/photo_pipeline/services/upload_verification.py

350 lines
14 KiB
Python

"""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
],
}