Files
photoanalyzer/photo_pipeline/services/uploads.py

307 lines
13 KiB
Python

"""UploadService — preflight for Immich upload (US05-01).
Upload is the first stage that sends the library somewhere the app cannot take it
back from, so nothing here uploads: preflight only *proves* a scope is safe and
issues a token that a later start command must present (US05-02).
What it proves (concept §8 "preflight"):
- credentials are configured and the Immich server answers, without ever putting
the API key in a response, a preview, or a log line;
- ``immich-go`` is installed and its version is recorded;
- no rename is half-applied — the journal must not block library mutation;
- every asset in scope is canonical, present, and decided ``sfw``/``nsfw`` with a
verified safety EXIF checkpoint;
- every SFW asset also has a completed analysis EXIF checkpoint;
- the bytes on disk right now still hash to what inventory recorded, so the album
preview describes the exact bytes that would be uploaded.
Blocker codes are structured, never prose the UI has to parse:
``no_library_root``, ``credentials_missing``, ``server_unreachable``,
``immich_go_missing``, ``rename_pending``, ``unknown_album``, ``empty_scope``,
``file_missing``, ``bytes_changed``, ``safety_undecided``, ``safety_deferred``,
``safety_exif_unverified``, ``analysis_incomplete``, ``partial_scope``.
**Partial scope is a blocker, not a default.** An album with any blocked asset is
refused unless the caller passes the explicit ``allow_partial`` policy, which is
itself part of the token — a token issued for a partial upload can never be
replayed as a full one.
The token is derived, not stored: it is a digest of the whole report (minus its
timestamp), so any change that matters — a new decision, edited bytes, a different
server, a resolved blocker, a different policy — produces a different token and the
old one is stale by construction. No table, no invalidation bookkeeping.
"""
from __future__ import annotations
import hashlib
import json
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.models import AnalysisResult, Asset, SafetyReview
from photo_pipeline.services.albums import album_label
from photo_pipeline.services.hashing import sha256_file
from photo_pipeline.services.rename_journal import RenameJournal
PREFLIGHT_VERSION = 1
TOKEN_PREFIX = f"v{PREFLIGHT_VERSION}"
SFW = "sfw"
NSFW = "nsfw"
ANALYZED = "analyzed"
class UploadError(RuntimeError):
"""Invalid preflight request (unknown album in the requested scope)."""
def _now() -> datetime:
return datetime.now(timezone.utc)
def _issue(code: str, message: str) -> dict:
return {"code": code, "message": message}
class UploadService:
def __init__(self, session_factory: sessionmaker, *, config: Config) -> None:
self._session_factory = session_factory
self._config = config
self._roots = tuple(Path(root) for root in config.library_roots)
# ── preflight ─────────────────────────────────────────────────────────────
def preflight(self, albums: list[str] | None = None, *, allow_partial: bool = False) -> dict:
"""Validate an upload scope and issue its token. Read-only, no upload."""
report = {
"schema_version": PREFLIGHT_VERSION,
"policy": {"allow_partial": allow_partial},
"blockers": [],
"credentials": self._credentials(),
"server": self._server(),
"uploader": self._uploader(),
}
report["blockers"] += self._environment_blockers(report)
report["albums"] = self._albums(albums, allow_partial=allow_partial)
report["totals"] = _totals(report["albums"])
if not report["albums"]:
report["blockers"].append(
_issue("empty_scope", "no canonical, active assets are in the selected scope")
)
report["state"] = (
"ready"
if not report["blockers"] and all(a["state"] == "ready" for a in report["albums"])
else "blocked"
)
report["token"] = _token(report)
report["generated_at"] = _now().isoformat()
return report
def verify_token(
self, token: str, albums: list[str] | None = None, *, allow_partial: bool = False
) -> bool:
"""True when ``token`` still describes the current state of that scope.
Recomputed rather than looked up, so an externally edited file or a changed
decision invalidates it even though nothing wrote to the database.
"""
return bool(token) and token == self.preflight(albums, allow_partial=allow_partial)["token"]
# ── environment ───────────────────────────────────────────────────────────
def _credentials(self) -> dict:
"""Presence only — the key itself never leaves configuration."""
return {
"server_url": self._config.immich_server_url,
"api_key_configured": self._config.immich_api_key is not None,
}
def _server(self) -> dict:
reachable, detail = immich_go.ping(self._config.immich_server_url)
return {"reachable": reachable, "detail": detail}
def _uploader(self) -> dict:
binary = self._config.immich_go_binary
return {
"binary": binary,
"installed": immich_go.find_binary(binary) is not None,
"version": immich_go.version(binary),
}
def _environment_blockers(self, report: dict) -> list[dict]:
blockers: list[dict] = []
if not self._roots:
blockers.append(_issue("no_library_root", "no library root is configured"))
if not report["credentials"]["api_key_configured"] or not self._config.immich_server_url:
blockers.append(
_issue("credentials_missing", "an Immich server URL and API key are required")
)
elif not report["server"]["reachable"]:
blockers.append(
_issue(
"server_unreachable", f"Immich did not respond: {report['server']['detail']}"
)
)
if not report["uploader"]["installed"]:
blockers.append(
_issue("immich_go_missing", f"{self._config.immich_go_binary} is not installed")
)
if RenameJournal(self._session_factory).blocks_mutation():
blockers.append(
_issue("rename_pending", "an unresolved rename must be recovered before upload")
)
return blockers
# ── scope ─────────────────────────────────────────────────────────────────
def _albums(self, requested: list[str] | None, *, allow_partial: bool) -> list[dict]:
by_album = self._scope()
if requested is not None:
unknown = sorted(set(requested) - set(by_album))
if unknown:
raise UploadError(f"unknown album(s): {', '.join(unknown)}")
by_album = {name: by_album[name] for name in sorted(set(requested))}
return [
self._album(name, rows, allow_partial=allow_partial)
for name, rows in sorted(by_album.items())
]
def _scope(self) -> dict[str, list[dict]]:
"""Canonical, active assets grouped by album, each with its stage evidence."""
with self._session_factory() as session:
assets = list(
session.scalars(
select(Asset).where(
Asset.canonical_asset_id.is_(None),
Asset.availability_state == "active",
Asset.current_path.is_not(None),
)
)
)
reviews: dict[str, SafetyReview] = {}
for review in session.scalars(select(SafetyReview).order_by(SafetyReview.created_at)):
reviews[review.asset_id] = review # latest row per asset wins
analyses = {
result.asset_id: result for result in session.scalars(select(AnalysisResult))
}
by_album: dict[str, list[dict]] = {}
for asset in assets:
by_album.setdefault(album_label(asset.current_path, self._roots), []).append(
{
"asset_id": asset.id,
"path": asset.current_path,
"expected_sha256": asset.current_sha256,
"review": reviews.get(asset.id),
"analysis": analyses.get(asset.id),
}
)
return by_album
def _album(self, name: str, rows: list[dict], *, allow_partial: bool) -> dict:
folder = Path(rows[0]["path"]).parent
items = sorted((self._item(row) for row in rows), key=lambda item: item["current_path"])
blocked = [item for item in items if item["blockers"]]
eligible = [item for item in items if not item["blockers"]]
blockers: list[dict] = []
if blocked and not allow_partial:
blockers.append(
_issue(
"partial_scope",
f"{len(blocked)} of {len(items)} asset(s) are not upload-ready; resolve them "
"or approve a partial upload explicitly",
)
)
if not eligible:
blockers.append(_issue("empty_scope", "no upload-ready asset remains in this album"))
# Folder-as-album: Immich names the album after the leaf folder, so the
# preview shows exactly what the server will create.
album_name = folder.name
return {
"album": name,
"folder": str(folder),
"album_name": album_name,
"asset_count": len(items),
"eligible_count": len(eligible),
"blocked_count": len(blocked),
"partial": bool(blocked),
"state": "blocked" if blockers else "ready",
"blockers": blockers,
"assets": items,
"command_preview": immich_go.preview_command(
binary=self._config.immich_go_binary,
server_url=self._config.immich_server_url,
album_name=album_name,
folder=folder,
),
}
def _item(self, row: dict) -> dict:
"""One asset's readiness, including its hash *as it is on disk right now*."""
path = Path(row["path"])
review: SafetyReview | None = row["review"]
analysis: AnalysisResult | None = row["analysis"]
decision = review.decision if review else None
blockers: list[dict] = []
current_sha256 = None
if not path.exists():
blockers.append(_issue("file_missing", f"{path} is missing"))
else:
# ponytail: full re-hash every preflight. Gate on (size, mtime_ns) first
# if a large library makes this slow — the hash stays authoritative.
current_sha256 = sha256_file(path)
if row["expected_sha256"] and current_sha256 != row["expected_sha256"]:
blockers.append(
_issue("bytes_changed", f"{path} changed since its last verified checkpoint")
)
if decision not in (SFW, NSFW):
code = "safety_deferred" if decision == "deferred" else "safety_undecided"
blockers.append(_issue(code, "a confirmed sfw/nsfw safety decision is required"))
elif review.exif_verified_at is None:
blockers.append(
_issue("safety_exif_unverified", "the safety EXIF checkpoint is not verified")
)
elif decision == SFW and not (
analysis and analysis.status == ANALYZED and analysis.exif_written_at is not None
):
blockers.append(
_issue("analysis_incomplete", "SFW assets need a verified analysis EXIF checkpoint")
)
return {
"asset_id": row["asset_id"],
"current_path": str(path),
"safety_decision": decision,
"current_sha256": current_sha256,
"blockers": blockers,
}
def _totals(albums: list[dict]) -> dict:
return {
"albums": len(albums),
"ready_albums": sum(1 for album in albums if album["state"] == "ready"),
"assets": sum(album["asset_count"] for album in albums),
"eligible": sum(album["eligible_count"] for album in albums),
"blocked": sum(album["blocked_count"] for album in albums),
}
def _token(report: dict) -> str:
"""Digest of everything the report asserts. Volatile fields are excluded so the
same state always yields the same token; every relevant change breaks it."""
payload = {key: value for key, value in report.items() if key not in ("generated_at", "token")}
digest = hashlib.sha256(
json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
).hexdigest()
return f"{TOKEN_PREFIX}:{digest}"