US06-05: Operate Archive and Restore in the Browser (#81)

This commit was merged in pull request #81.
This commit is contained in:
2026-08-16 20:39:17 +02:00
parent fb8567284d
commit ea65bb764c
7 changed files with 1315 additions and 0 deletions

View File

@@ -393,6 +393,115 @@ def mark_upload_ready(seeded: Seeded, *, unverified: tuple[str, ...] = ()) -> No
session.commit()
# ── Phase F: an archivable album and a mountable fake medium ─────────────────
def mark_uploaded(seeded: Seeded, *, album: str = "rome") -> None:
"""Give every seeded photo the verified upload evidence archiving requires.
Archiving refuses anything Immich is not proven to hold, and that proof is an
upload batch — recorded here as fixture state so the archive journeys do not
have to re-run an upload they are not testing.
"""
from sqlalchemy import select
from photo_pipeline.models import Asset, UploadBatch, UploadItem
from photo_pipeline.services.hashing import sha256_file
with session_factory(seeded) as sf:
with sf() as session:
batch_id = str(uuid.uuid4())
session.add(
UploadBatch(
id=batch_id,
album=album,
folder=str(seeded.lib / album),
album_name=album,
state="succeeded",
preflight_token="v1:e2e",
outcome_state="verified",
created_at=NOW,
)
)
for asset in session.scalars(select(Asset)):
if not asset.current_path:
continue
session.add(
UploadItem(
batch_id=batch_id,
asset_id=asset.id,
path=asset.current_path,
sha256=sha256_file(asset.current_path),
sha1="0" * 40,
state="sent",
outcome="uploaded",
)
)
session.commit()
class ArchiveStack:
"""A seeded, archivable library plus the server, the worker, and a fake medium.
The medium is an ordinary directory whose marker file makes it identifiable;
``unmount()`` takes that marker away, which is exactly what the application sees
when an external disk is unplugged.
"""
MARKER = ".photo-pipeline-archive.json"
def __init__(self, tmp_path: Path, seeded: Seeded) -> None:
self.tmp_path = tmp_path
self.seeded = seeded
self.archive = tmp_path / "archive"
self.archive.mkdir(exist_ok=True)
self.server: Server | None = None
self.worker: subprocess.Popen | None = None
self.base = ""
def start(self, *, worker: bool = True, extra_env: dict[str, str] | None = None) -> "ArchiveStack":
env = {"PHOTO_PIPELINE_ARCHIVE_FREE_SPACE_RESERVE_BYTES": "0", **(extra_env or {})}
self.server = Server(self.seeded, extra_env=env).start()
self.base = self.server.base
if worker:
self.worker = start_worker(self.seeded, extra_env=env)
return self
def register(self, name: str = "external") -> dict:
response = httpx.post(
f"{self.base}/api/v1/archive-locations",
json={"name": name, "root": str(self.archive)},
timeout=20,
)
response.raise_for_status()
return response.json()
def unmount(self) -> None:
(self.archive / self.MARKER).rename(self.archive / f"{self.MARKER}.away")
def remount(self) -> None:
(self.archive / f"{self.MARKER}.away").rename(self.archive / self.MARKER)
def plans(self) -> list[dict]:
return httpx.get(f"{self.base}/api/v1/archive-plans", timeout=20).json()["plans"]
def assets(self) -> list[dict]:
return httpx.get(
f"{self.base}/api/v1/inventory/assets", params={"limit": 200}, timeout=20
).json()["items"]
def restart_server(self) -> None:
self.server.stop()
self.server.start()
def stop(self) -> None:
if self.worker is not None:
self.worker.kill()
self.worker.wait(timeout=10)
if self.server is not None:
self.server.stop()
class UploadStack:
"""A seeded, upload-ready library plus the server, worker, and fake Immich."""