350 lines
14 KiB
Python
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
|
|
],
|
|
}
|