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