US06-03: Preserve Offline Identity and Review Evidence (#79)
This commit was merged in pull request #79.
This commit is contained in:
@@ -8,6 +8,11 @@ an EXIF-only edit reuses the file while a real pixel change invalidates it; writ
|
||||
are atomic and the cache is bounded by an LRU quota. Failures are persisted as typed
|
||||
errors so a broken original is not retried on every request.
|
||||
|
||||
An archived asset is served from its medium when that medium is mounted, and from
|
||||
its *protected* preview when it is not (US06-03). Protected previews are evidence,
|
||||
not cache: the quota never evicts them, because the original they describe may be
|
||||
unreachable when duplicate review needs it.
|
||||
|
||||
Reuses photo_analyzer.prepare_image decode/resize/HEIC handling, adding the missing
|
||||
EXIF-orientation step, WebP output, and a managed cache (donor_ledger.yaml:
|
||||
pa-imaging).
|
||||
@@ -26,6 +31,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
from photo_pipeline import path_policy
|
||||
from photo_pipeline.config import Config
|
||||
from photo_pipeline.models import Asset, Thumbnail
|
||||
from photo_pipeline.services import availability
|
||||
|
||||
# Best-effort HEIC support: registered only if the optional decoder is installed.
|
||||
try: # pragma: no cover - depends on an optional native dependency
|
||||
@@ -38,6 +44,8 @@ except Exception: # pragma: no cover
|
||||
SIZES = (256, 512, 1280)
|
||||
THUMB_VERSION = 1
|
||||
THUMB_FORMAT = "webp"
|
||||
# The size kept as durable comparison evidence for archived assets (concept §9).
|
||||
PROTECTED_SIZE = 1280
|
||||
|
||||
|
||||
class ThumbnailError(RuntimeError):
|
||||
@@ -86,7 +94,12 @@ class ThumbnailService:
|
||||
self._config = config
|
||||
self._cache_dir = config.thumbnail_cache_dir
|
||||
|
||||
def generate(self, asset_id: str, size: int) -> Path:
|
||||
def generate(
|
||||
self, asset_id: str, size: int, *, protected: bool = False, source: Path | None = None
|
||||
) -> Path:
|
||||
"""Render (or reuse) a preview. ``source`` overrides where the bytes are read
|
||||
from — the archiver passes its verified archive copy, which the database does
|
||||
not yet point at while the transfer is still in flight."""
|
||||
if size not in SIZES:
|
||||
raise InvalidSize(f"size must be one of {SIZES}")
|
||||
|
||||
@@ -94,9 +107,7 @@ class ThumbnailService:
|
||||
asset = session.get(Asset, asset_id)
|
||||
if asset is None:
|
||||
raise ThumbnailNotFound(f"unknown asset {asset_id}")
|
||||
if asset.availability_state != "active" or not asset.current_path:
|
||||
raise ThumbnailUnavailable(f"asset {asset_id} has no active file")
|
||||
self._validate_path(asset.current_path)
|
||||
archived = asset.availability_state in availability.ARCHIVED
|
||||
cache_key = self._cache_key(asset, size)
|
||||
|
||||
row = session.get(Thumbnail, cache_key)
|
||||
@@ -107,9 +118,18 @@ class ThumbnailService:
|
||||
)
|
||||
if row.path and Path(row.path).exists():
|
||||
_touch(row.path)
|
||||
if protected and not row.protected:
|
||||
self._protect(cache_key)
|
||||
return Path(row.path)
|
||||
|
||||
source = asset.current_path
|
||||
# An archived original is read from its medium; when that medium is not
|
||||
# mounted the retained preview above is the only evidence there is.
|
||||
source = source or availability.readable_path(session, asset)
|
||||
if source is None:
|
||||
raise ThumbnailUnavailable(f"asset {asset_id} has no readable file")
|
||||
source = str(source)
|
||||
if source == asset.current_path:
|
||||
self._validate_path(source) # archive roots lie outside the library
|
||||
|
||||
# Rendering happens outside the DB session (no transaction held during I/O).
|
||||
try:
|
||||
@@ -119,10 +139,61 @@ class ThumbnailService:
|
||||
self._record_error(cache_key, asset_id, size, error.code)
|
||||
raise
|
||||
|
||||
self._record_ready(cache_key, asset_id, size, rendered)
|
||||
# Archived assets keep their preview permanently: it is the comparison
|
||||
# evidence that survives the original leaving active storage.
|
||||
self._record_ready(cache_key, asset_id, size, rendered, protected=protected or archived)
|
||||
self._enforce_quota(keep=rendered["path"])
|
||||
return Path(rendered["path"])
|
||||
|
||||
def ensure_protected(self, asset_id: str, *, source: Path | None = None) -> dict:
|
||||
"""Produce (or confirm) the durable comparison preview for an asset.
|
||||
|
||||
Returns evidence rather than raising, because the caller — archive
|
||||
preflight and the transfer itself — decides what an unrenderable original
|
||||
means. ``unsupported`` is a recorded property of the file, not a failure of
|
||||
the policy: its hashes and metadata remain the comparison evidence.
|
||||
"""
|
||||
try:
|
||||
path = self.generate(asset_id, PROTECTED_SIZE, protected=True, source=source)
|
||||
except tuple(_PERSISTED_ERRORS) as error:
|
||||
return {"state": "unsupported", "error_code": error.code, "path": None}
|
||||
except ThumbnailError as error:
|
||||
return {"state": "unavailable", "error_code": error.code, "path": None}
|
||||
return {"state": "ready", "error_code": None, "path": str(path)}
|
||||
|
||||
def evidence(self, asset_id: str) -> dict:
|
||||
"""What durable preview this asset has right now, without rendering."""
|
||||
with self._session_factory() as session:
|
||||
rows = list(
|
||||
session.execute(
|
||||
select(Thumbnail).where(Thumbnail.asset_id == asset_id)
|
||||
).scalars()
|
||||
)
|
||||
for row in rows:
|
||||
if row.state == "ready" and row.path and Path(row.path).exists():
|
||||
return {
|
||||
"state": "ready",
|
||||
"protected": bool(row.protected),
|
||||
"size": row.size,
|
||||
"error_code": None,
|
||||
}
|
||||
for row in rows:
|
||||
if row.state == "error":
|
||||
return {
|
||||
"state": "unsupported",
|
||||
"protected": False,
|
||||
"size": row.size,
|
||||
"error_code": row.error_code,
|
||||
}
|
||||
return {"state": "missing", "protected": False, "size": None, "error_code": None}
|
||||
|
||||
def _protect(self, cache_key: str) -> None:
|
||||
with self._session_factory() as session:
|
||||
row = session.get(Thumbnail, cache_key)
|
||||
if row is not None:
|
||||
row.protected = True
|
||||
session.commit()
|
||||
|
||||
# ── path safety ──────────────────────────────────────────────────────────
|
||||
def _validate_path(self, current_path: str) -> None:
|
||||
path = Path(current_path)
|
||||
@@ -184,7 +255,9 @@ class ThumbnailService:
|
||||
}
|
||||
|
||||
# ── persistence ────────────────────────────────────────────────────────────
|
||||
def _record_ready(self, cache_key: str, asset_id: str, size: int, rendered: dict) -> None:
|
||||
def _record_ready(
|
||||
self, cache_key: str, asset_id: str, size: int, rendered: dict, *, protected: bool = False
|
||||
) -> None:
|
||||
with self._session_factory() as session:
|
||||
session.merge(
|
||||
Thumbnail(
|
||||
@@ -197,6 +270,7 @@ class ThumbnailService:
|
||||
width=rendered["width"],
|
||||
height=rendered["height"],
|
||||
format=rendered["format"],
|
||||
protected=protected,
|
||||
)
|
||||
)
|
||||
try:
|
||||
@@ -230,6 +304,10 @@ class ThumbnailService:
|
||||
if total <= quota:
|
||||
return
|
||||
files.sort(key=lambda f: f.stat().st_mtime) # least-recently-used first
|
||||
# Protected previews are evidence, not cache: an archived original cannot be
|
||||
# re-rendered once its medium is away, so eviction never touches them.
|
||||
protected = self._protected_paths()
|
||||
files = [f for f in files if str(f) not in protected]
|
||||
keep_path = str(Path(keep)) if keep else None
|
||||
evicted: list[str] = []
|
||||
for f in files:
|
||||
@@ -247,6 +325,16 @@ class ThumbnailService:
|
||||
if evicted:
|
||||
self._forget(evicted)
|
||||
|
||||
def _protected_paths(self) -> set[str]:
|
||||
with self._session_factory() as session:
|
||||
return {
|
||||
row.path
|
||||
for row in session.execute(
|
||||
select(Thumbnail).where(Thumbnail.protected.is_(True))
|
||||
).scalars()
|
||||
if row.path
|
||||
}
|
||||
|
||||
def _forget(self, paths: list[str]) -> None:
|
||||
with self._session_factory() as session:
|
||||
rows = session.execute(
|
||||
|
||||
Reference in New Issue
Block a user