641 lines
27 KiB
Python
641 lines
27 KiB
Python
"""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}"
|