"""ArchiveService — destinations and archive preflight (US06-01). Archive is the only stage that *removes* originals from the active library, so this service does the opposite of removing anything: it registers destinations and proves, before a single byte moves, that an album could be archived safely. The transfer itself is US06-02. An archive location is a medium, not a path (see :class:`ArchiveLocation`). A marker file on the medium carries its ``media_id``, so a disk mounted at the recorded root but holding a different marker is ``wrong_volume`` rather than silently accepted — the classic "the external disk came back at the same mountpoint" data-loss path. Preflight proves, per concept §9 "Archive preflight": - the album's upload is *verified*, not merely process-successful, and its bytes on disk still hash to exactly what was uploaded; - the destination medium is mounted, is the right one, is writable, lies outside every library root and every ``_IGNORE/`` tree, and has room for the scope plus a configured reserve; - nothing already occupies the destination; - no rename/upload/archive lease is held, and no rename is half-applied; - a database backup and the archive manifest can really be written — both are probed by writing them, not assumed. Blocker codes: ``no_library_root``, ``location_offline``, ``wrong_volume``, ``archive_pending``, ``unsafe_destination``, ``destination_not_writable``, ``manifest_unwritable``, ``insufficient_capacity``, ``backup_unavailable``, ``lock_conflict``, ``rename_pending``, ``empty_scope``, ``destination_collision``, ``upload_unverified``, ``bytes_changed``, ``file_missing``, ``preview_unavailable``. Preflight also *creates* the durable comparison preview of every asset in scope (US06-03): it is the evidence duplicate review falls back on once the original is on a medium that may be offline, so it has to exist before the original leaves. Like upload preflight, the confirmation token is *derived* from the report rather than stored: any change to the scope, the bytes, the destination, or the blockers produces a different token, so a stale browser confirmation can never apply. Values that drift without meaning anything (free space, backup size, timestamps) are left out of the digest. """ from __future__ import annotations import hashlib import json import os import shutil import sqlite3 import uuid from contextlib import closing 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_report as report_parser from photo_pipeline.jobs.domain_handlers import ARCHIVE_LOCK, LIBRARY_WRITE_LOCK, UPLOAD_LOCK from photo_pipeline.models import ArchiveLocation, Asset, UploadBatch, UploadItem from photo_pipeline.path_policy import PathPolicyError, is_excluded, normalize_root, resolve_within from photo_pipeline.services.albums import album_label from photo_pipeline.services.archive_journal import ArchiveJournal from photo_pipeline.services.availability import MARKER_NAME, read_marker as _read_marker from photo_pipeline.services.availability import refresh as refresh_availability from photo_pipeline.services.hashing import sha256_file from photo_pipeline.services.jobs import JobService from photo_pipeline.services.rename_journal import RenameJournal from photo_pipeline.services.thumbnails import ThumbnailService from photo_pipeline.services.upload_reports import VERIFIED PREFLIGHT_VERSION = 1 TOKEN_PREFIX = f"v{PREFLIGHT_VERSION}" MANIFEST_NAME = "archive-manifest.json" # Upload outcomes that prove Immich holds these exact bytes. ``skipped``/``failed``/ # ``unknown`` never qualify: archiving on them would remove the only copy. ARCHIVED_OUTCOMES = frozenset( {report_parser.UPLOADED, report_parser.UPGRADED, report_parser.DUPLICATE} ) LOCKS = (LIBRARY_WRITE_LOCK, UPLOAD_LOCK, ARCHIVE_LOCK) class ArchiveError(RuntimeError): """The request cannot be carried out (unknown location/album, unsafe root).""" def __init__(self, code: str, message: str) -> None: super().__init__(message) self.code = code def _now() -> datetime: return datetime.now(timezone.utc) def _issue(code: str, message: str) -> dict: return {"code": code, "message": message} class ArchiveService: def __init__(self, session_factory: sessionmaker, *, config: Config) -> None: self._session_factory = session_factory self._config = config self._roots = tuple(normalize_root(root) for root in config.library_roots) # ── locations ───────────────────────────────────────────────────────────── def register(self, name: str, root: str) -> dict: """Register an archive destination and stamp its medium with a marker. The marker is what makes the location identifiable later, so registering is the one archive operation that writes to the destination up front. """ name = (name or "").strip() if not name: raise ArchiveError("name_required", "an archive location needs a name") path = Path(root).expanduser() if not path.is_dir(): raise ArchiveError("root_missing", f"{path} is not an existing directory") path = normalize_root(path) unsafe = self._unsafe_destination(path) if unsafe: raise ArchiveError("unsafe_destination", unsafe) marker = _read_marker(path) with self._session_factory() as session: if session.scalar(select(ArchiveLocation).where(ArchiveLocation.name == name)): raise ArchiveError("duplicate_name", f"an archive location named {name!r} exists") if marker and session.scalar( select(ArchiveLocation).where(ArchiveLocation.media_id == marker.get("media_id")) ): raise ArchiveError( "already_registered", f"{path} already belongs to another archive location" ) media_id = marker.get("media_id") if marker else str(uuid.uuid4()) error = _probe_write( path / MARKER_NAME, json.dumps({"media_id": media_id, "name": name}, indent=2).encode("utf-8"), keep=True, ) if error: raise ArchiveError("destination_not_writable", error) location = ArchiveLocation( id=str(uuid.uuid4()), name=name, root=str(path), media_id=media_id, state="online", last_seen_at=_now(), ) location.capabilities = json.dumps(_capabilities(path)) session.add(location) session.commit() return self._location_report(location, probe=_probe_location(location)) def locations(self) -> list[dict]: """Every configured location with a fresh probe of its medium.""" with self._session_factory() as session: rows = list(session.scalars(select(ArchiveLocation).order_by(ArchiveLocation.name))) reports = [] for location in rows: probe = _probe_location(location) location.state = probe["state"] if probe["state"] == "online": location.last_seen_at = _now() location.capabilities = json.dumps(probe["capabilities"]) reports.append(self._location_report(location, probe=probe)) session.commit() # A medium that just appeared or vanished changes what is readable, so the # archived assets are re-derived from the same probe (US06-03). refresh_availability(self._session_factory) return reports # ── preflight ───────────────────────────────────────────────────────────── def preflight(self, location_id: str, albums: list[str] | None = None) -> dict: """Validate an archive scope against a destination and issue its token. Read-only with respect to the library: it hashes files, probes the destination with its own temporary files, and writes nothing else. """ with self._session_factory() as session: location = session.get(ArchiveLocation, location_id) if location is None: raise ArchiveError("unknown_location", f"unknown archive location {location_id!r}") probe = _probe_location(location) location.state = probe["state"] if probe["state"] == "online": location.last_seen_at = _now() location.capabilities = json.dumps(probe["capabilities"]) report = { "schema_version": PREFLIGHT_VERSION, "location": self._location_report(location, probe=probe), "blockers": [], } root = Path(location.root) session.commit() report["blockers"] += self._destination_blockers(root, probe) report["blockers"] += self._lock_blockers() report["albums"] = self._albums(albums, root, reachable=probe["state"] == "online") report["totals"] = _totals(report["albums"]) report["capacity"] = self._capacity(report["totals"]["bytes"], probe) if not report["capacity"]["sufficient"]: report["blockers"].append( _issue( "insufficient_capacity", # The free-space number is deliberately left out: it drifts between # two identical preflights, and the token is a digest of this text, # so quoting it here would invalidate every approval instantly. f"{report['totals']['bytes']} B plus a " f"{self._config.archive_free_space_reserve_bytes} B reserve do not fit on " "the medium", ) ) report["backup"] = self._backup_probe() if not report["backup"]["ok"]: report["blockers"].append( _issue( "backup_unavailable", f"a database backup could not be written: {report['backup']['detail']}", ) ) report["manifest"] = self._manifest_probe( root, report["albums"], writable=probe["writable"] ) if not report["manifest"]["ok"]: report["blockers"].append( _issue( "manifest_unwritable", f"the archive manifest could not be written: {report['manifest']['detail']}", ) ) 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, location_id: str, albums: list[str] | None = None) -> bool: """True when ``token`` still describes this scope and this destination. Recomputed, never looked up: an edited source file, a swapped medium, or a newly occupied destination invalidates it without anything writing to the database. """ return bool(token) and token == self.preflight(location_id, albums)["token"] # ── destination ─────────────────────────────────────────────────────────── def _unsafe_destination(self, root: Path) -> str | None: """Why this root may never hold archived originals, or ``None``.""" if is_excluded(root): return f"{root} is inside an excluded (_IGNORE/) tree" for library in self._roots: if root == library or library in root.parents or root in library.parents: return f"{root} overlaps the active library root {library}" return None def _destination_blockers(self, root: Path, probe: dict) -> list[dict]: blockers: list[dict] = [] if not self._roots: blockers.append(_issue("no_library_root", "no library root is configured")) if probe["state"] == "offline": blockers.append( _issue("location_offline", f"the archive medium is not mounted at {root}") ) elif probe["state"] == "wrong_volume": blockers.append( _issue( "wrong_volume", f"{root} holds a different archive medium ({probe['detail']})", ) ) unsafe = self._unsafe_destination(root) if unsafe: blockers.append(_issue("unsafe_destination", unsafe)) if probe["state"] == "unwritable": blockers.append( _issue("destination_not_writable", f"{root} is not writable: {probe['detail']}") ) return blockers def _lock_blockers(self) -> list[dict]: """Archive is blocked by any lease that may still be moving bytes or metadata.""" blockers: list[dict] = [] jobs = JobService(self._session_factory) for lock in LOCKS: held = jobs.blockers(lock) if held: blockers.append( _issue("lock_conflict", f"the {lock} lane is busy: job {held[0]['id']}") ) if RenameJournal(self._session_factory).blocks_mutation(): blockers.append( _issue("rename_pending", "an unresolved rename must be recovered before archiving") ) if ArchiveJournal(self._session_factory).blocks_mutation(): blockers.append( _issue( "archive_pending", "an unresolved archive transfer must be recovered before archiving again", ) ) return blockers def _capacity(self, required: int, probe: dict) -> dict: reserve = self._config.archive_free_space_reserve_bytes free = probe["free_bytes"] return { "required_bytes": required, "reserve_bytes": reserve, "free_bytes": free, "sufficient": free is not None and free >= required + reserve, } def _backup_probe(self) -> dict: """Write a real online backup of the database, then discard it. A backup that is merely assumed to be possible is worth nothing on the day the archive removes the originals, so this actually runs SQLite's backup API. """ source = self._config.database_path target = source.parent / f".archive-preflight-backup-{uuid.uuid4()}.db" try: with closing(sqlite3.connect(source)) as src, closing(sqlite3.connect(target)) as dst: src.backup(dst) size = target.stat().st_size except (sqlite3.Error, OSError) as error: return {"ok": False, "bytes": None, "detail": str(error)} finally: target.unlink(missing_ok=True) return {"ok": True, "bytes": size, "detail": None} def _manifest_probe(self, root: Path, albums: list[dict], *, writable: bool) -> dict: """Prove the manifest can be created by writing this exact content and removing it again. The real manifest is written by the transfer (US06-02).""" manifest = { "schema_version": PREFLIGHT_VERSION, "albums": [ { "album": album["album"], "destination": album["destination"], "files": [ { "asset_id": asset["asset_id"], "source": asset["current_path"], "sha256": asset["current_sha256"], "byte_size": asset["byte_size"], } for asset in album["assets"] ], } for album in albums ], } payload = json.dumps(manifest, indent=2, sort_keys=True).encode("utf-8") if not writable: return {"ok": False, "bytes": len(payload), "detail": "the destination is unavailable"} error = _probe_write(root / f".{MANIFEST_NAME}.probe-{uuid.uuid4()}", payload) return {"ok": error is None, "bytes": len(payload), "detail": error} def _location_report(self, location: ArchiveLocation, *, probe: dict) -> dict: return { "id": location.id, "name": location.name, "root": location.root, "media_id": location.media_id, "state": probe["state"], "writable": probe["writable"], "device_id": probe["device_id"], "detail": probe["detail"], "last_seen_at": location.last_seen_at.isoformat() if location.last_seen_at else None, } # ── scope ───────────────────────────────────────────────────────────────── def _albums(self, requested: list[str] | None, root: Path, *, reachable: bool) -> list[dict]: by_album = self._scope() if requested is not None: unknown = sorted(set(requested) - set(by_album)) if unknown: raise ArchiveError("unknown_album", f"unknown album(s): {', '.join(unknown)}") by_album = {name: by_album[name] for name in sorted(set(requested))} return [ self._album(name, rows, root, reachable=reachable) for name, rows in sorted(by_album.items()) ] def _scope(self) -> dict[str, list[dict]]: """Canonical, active assets grouped by album, each with its upload 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), ) ) ) uploads: dict[str, UploadItem] = {} for item, batch in session.execute( select(UploadItem, UploadBatch) .join(UploadBatch, UploadBatch.id == UploadItem.batch_id) .order_by(UploadBatch.created_at) ): if _proves_upload(item, batch): uploads[item.asset_id] = item # the latest verified batch wins 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, "byte_size": asset.byte_size, "upload": uploads.get(asset.id), } ) return by_album def _album(self, name: str, rows: list[dict], root: Path, *, reachable: bool) -> dict: folder = Path(rows[0]["path"]).parent items = sorted( (self._with_preview(_item(row)) for row in rows), key=lambda item: item["current_path"], ) blocked = [item for item in items if item["blockers"]] blockers: list[dict] = [] destination = root / name try: resolve_within(root, destination) except PathPolicyError as error: blockers.append(_issue("unsafe_destination", str(error))) if reachable and destination.exists() and any(destination.iterdir()): blockers.append( _issue("destination_collision", f"{destination} already exists and is not empty") ) if blocked: blockers.append( _issue( "partial_scope", f"{len(blocked)} of {len(items)} asset(s) are not archivable; an album is " "archived whole or not at all", ) ) return { "album": name, "folder": str(folder), "destination": str(destination), # Same filesystem means the transfer can be an atomic move; anything else # is copy-verify-remove (concept §9). "transfer_method": _transfer_method(folder, root), "asset_count": len(items), "blocked_count": len(blocked), "reclaimable_bytes": sum(item["byte_size"] or 0 for item in items), "state": "blocked" if blockers else "ready", "blockers": blockers, "assets": items, } def _with_preview(self, item: dict) -> dict: """Create the durable comparison preview while the original is still here. This is the last moment it can be made: once the file is archived and the medium leaves, only the retained preview can answer "is this new photo the same picture?". An original that cannot be decoded at all has no preview to keep — its hashes and metadata stay the evidence — but a preview that fails for any other reason blocks the archive (concept §9). """ preview = self._previews().ensure_protected(item["asset_id"]) item["preview"] = preview if preview["state"] == "unavailable" and not item["blockers"]: item["blockers"].append( _issue( "preview_unavailable", f"a durable comparison preview of {item['current_path']} could not be " f"created ({preview['error_code']})", ) ) return item def _previews(self) -> ThumbnailService: return ThumbnailService(self._session_factory, self._config) # ── internals ──────────────────────────────────────────────────────────────── def _item(row: dict) -> dict: """One asset's archivability: verified upload plus the bytes on disk right now.""" path = Path(row["path"]) upload: UploadItem | None = row["upload"] blockers: list[dict] = [] current_sha256 = None if not path.exists(): blockers.append(_issue("file_missing", f"{path} is missing")) else: # ponytail: full re-hash of the scope. Gate on (size, mtime_ns) first if a # large album makes this slow — the hash stays the authority. current_sha256 = sha256_file(path) if upload is None: blockers.append( _issue("upload_unverified", "a verified Immich upload of these bytes is required") ) elif current_sha256 is not None and upload.sha256 and current_sha256 != upload.sha256: blockers.append( _issue("bytes_changed", f"{path} changed since it was uploaded; re-upload it first") ) return { "asset_id": row["asset_id"], "current_path": str(path), "byte_size": row["byte_size"], "current_sha256": current_sha256, "uploaded_sha256": upload.sha256 if upload else None, "blockers": blockers, } def _proves_upload(item: UploadItem, batch: UploadBatch) -> bool: """Whether this upload item is evidence that Immich holds these exact bytes.""" return ( batch.outcome_state == VERIFIED and not batch.stale_bytes and not item.changed_after_upload and item.outcome in ARCHIVED_OUTCOMES ) def _transfer_method(folder: Path, root: Path) -> str: try: if folder.stat().st_dev == root.stat().st_dev: return "move" except OSError: pass return "copy_verify_remove" def _probe_write(path: Path, payload: bytes, *, keep: bool = False) -> str | None: """Write ``payload`` to ``path``; return the failure detail or ``None``.""" try: path.write_bytes(payload) except OSError as error: return str(error) if not keep: try: path.unlink() except OSError as error: return str(error) return None def _capabilities(root: Path) -> dict: usage = shutil.disk_usage(root) return { "device_id": root.stat().st_dev, "total_bytes": usage.total, "writable": os.access(root, os.W_OK), } def _probe_location(location: ArchiveLocation) -> dict: """Is the right medium mounted, and can it take bytes right now?""" root = Path(location.root) blank = {"device_id": None, "free_bytes": None, "total_bytes": None, "capabilities": {}} if not root.is_dir(): return {"state": "offline", "writable": False, "detail": f"{root} is not mounted", **blank} marker = _read_marker(root) if marker is None: return { "state": "offline", "writable": False, "detail": f"no archive marker found at {root}", **blank, } if marker.get("media_id") != location.media_id: return { "state": "wrong_volume", "writable": False, "detail": f"marker media_id {marker.get('media_id')!r}", **blank, } capabilities = _capabilities(root) usage = shutil.disk_usage(root) # os.access lies on some filesystems; a real write is the only proof. detail = _probe_write(root / f".archive-write-probe-{uuid.uuid4()}", b"") return { "state": "online" if detail is None else "unwritable", "writable": detail is None, "detail": detail, "device_id": capabilities["device_id"], "free_bytes": usage.free, "total_bytes": usage.total, "capabilities": capabilities, } 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), "blocked": sum(album["blocked_count"] for album in albums), "bytes": sum(album["reclaimable_bytes"] for album in albums), } def _token(report: dict) -> str: """Digest of everything the report asserts about the scope and the destination. Values that drift without changing what would happen — free space, backup size, timestamps — are excluded so the same situation always yields the same token. """ payload = {key: value for key, value in report.items() if key not in ("generated_at", "token")} payload["location"] = { key: value for key, value in payload["location"].items() if key != "last_seen_at" } payload["capacity"] = { key: value for key, value in payload["capacity"].items() if key != "free_bytes" } payload["backup"] = {key: value for key, value in payload["backup"].items() if key != "bytes"} digest = hashlib.sha256( json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8") ).hexdigest() return f"{TOKEN_PREFIX}:{digest}"