307 lines
13 KiB
Python
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}"
|