US06-03: Preserve Offline Identity and Review Evidence (#79)
This commit was merged in pull request #79.
This commit is contained in:
@@ -28,7 +28,11 @@ Blocker codes: ``no_library_root``, ``location_offline``, ``wrong_volume``,
|
||||
``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``.
|
||||
``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
|
||||
@@ -59,14 +63,16 @@ from photo_pipeline.models import ArchiveLocation, Asset, UploadBatch, UploadIte
|
||||
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}"
|
||||
MARKER_NAME = ".photo-pipeline-archive.json"
|
||||
MANIFEST_NAME = "archive-manifest.json"
|
||||
|
||||
# Upload outcomes that prove Immich holds these exact bytes. ``skipped``/``failed``/
|
||||
@@ -163,7 +169,10 @@ class ArchiveService:
|
||||
location.capabilities = json.dumps(probe["capabilities"])
|
||||
reports.append(self._location_report(location, probe=probe))
|
||||
session.commit()
|
||||
return reports
|
||||
# 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -420,7 +429,10 @@ class ArchiveService:
|
||||
|
||||
def _album(self, name: str, rows: list[dict], root: Path, *, reachable: bool) -> dict:
|
||||
folder = Path(rows[0]["path"]).parent
|
||||
items = sorted((_item(row) for row in rows), key=lambda item: item["current_path"])
|
||||
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] = []
|
||||
|
||||
@@ -456,6 +468,30 @@ class ArchiveService:
|
||||
"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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -512,13 +548,6 @@ def _transfer_method(folder: Path, root: Path) -> str:
|
||||
return "copy_verify_remove"
|
||||
|
||||
|
||||
def _read_marker(root: Path) -> dict | None:
|
||||
try:
|
||||
return json.loads((root / MARKER_NAME).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _probe_write(path: Path, payload: bytes, *, keep: bool = False) -> str | None:
|
||||
"""Write ``payload`` to ``path``; return the failure detail or ``None``."""
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user