US06-03: Preserve Offline Identity and Review Evidence (#79)
This commit was merged in pull request #79.
This commit is contained in:
@@ -13,8 +13,11 @@ renames. Every discovered or absent path is classified as one occurrence:
|
||||
- ``missing`` — a known active asset whose file is gone (kept, flagged).
|
||||
|
||||
Missing files are never pruned (that would break identity); the asset is retained
|
||||
with ``missing_at`` set. Archived assets are left untouched. Rescanning unchanged
|
||||
input makes no durable change.
|
||||
with ``missing_at`` set and its availability becomes ``missing_unexpected`` —
|
||||
nothing explains where the bytes went. Archived assets are left untouched: their
|
||||
absence from the active roots is expected, and each scan re-derives whether their
|
||||
medium is reachable (:mod:`photo_pipeline.services.availability`). Rescanning
|
||||
unchanged input makes no durable change.
|
||||
|
||||
Extracted from photo_analyzer.discover_photos/reconcile_moved/prune_missing
|
||||
(see donor_ledger.yaml: pa-discovery, pa-prune-missing).
|
||||
@@ -30,12 +33,12 @@ from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from photo_pipeline import path_policy
|
||||
from photo_pipeline.models import Asset, AssetPath
|
||||
from photo_pipeline.services import hashing
|
||||
from photo_pipeline.services import availability, hashing
|
||||
|
||||
|
||||
class Occurrence(str, Enum):
|
||||
@@ -60,6 +63,8 @@ def _asset_dict(asset: Asset) -> dict:
|
||||
"id": asset.id,
|
||||
"current_path": asset.current_path,
|
||||
"availability_state": asset.availability_state,
|
||||
"archive_location_id": asset.archive_location_id,
|
||||
"archive_path": asset.archive_path,
|
||||
"byte_size": asset.byte_size,
|
||||
"current_sha256": asset.current_sha256,
|
||||
"pixel_sha256": asset.pixel_sha256,
|
||||
@@ -101,7 +106,9 @@ class InventoryService:
|
||||
result.asset_ids[str(path)] = asset.id
|
||||
|
||||
for asset in assets:
|
||||
if asset.availability_state != "active" or asset.id in seen_ids:
|
||||
# Archived assets are explained by their location, not by the active
|
||||
# roots: a scan must never prune or flag them (concept §9).
|
||||
if asset.availability_state in availability.ARCHIVED or asset.id in seen_ids:
|
||||
continue
|
||||
if asset.current_path and asset.current_path not in discovered_paths:
|
||||
if not Path(asset.current_path).exists():
|
||||
@@ -109,10 +116,18 @@ class InventoryService:
|
||||
asset.missing_at = now
|
||||
asset.state_version += 1
|
||||
asset.updated_at = now
|
||||
# Nothing explains this absence — it is not an offline medium.
|
||||
if asset.availability_state != availability.MISSING_UNEXPECTED:
|
||||
asset.availability_state = availability.MISSING_UNEXPECTED
|
||||
asset.state_version += 1
|
||||
asset.updated_at = now
|
||||
result.occurrences[asset.current_path] = Occurrence.MISSING.value
|
||||
|
||||
session.commit()
|
||||
|
||||
# Media may have been mounted or removed since the last scan.
|
||||
availability.refresh(self._session_factory)
|
||||
|
||||
result.counts = dict(Counter(result.occurrences.values()))
|
||||
return result
|
||||
|
||||
@@ -132,7 +147,11 @@ class InventoryService:
|
||||
if availability:
|
||||
stmt = stmt.where(Asset.availability_state == availability)
|
||||
if query:
|
||||
stmt = stmt.where(Asset.current_path.like(f"%{query}%"))
|
||||
like = f"%{query}%"
|
||||
# An archived asset has no active path; it is searched where it lives.
|
||||
stmt = stmt.where(
|
||||
or_(Asset.current_path.like(like), Asset.archive_path.like(like))
|
||||
)
|
||||
total = session.scalar(select(func.count()).select_from(stmt.subquery()))
|
||||
rows = session.execute(
|
||||
stmt.order_by(Asset.current_path).limit(limit).offset(offset)
|
||||
@@ -169,6 +188,7 @@ class InventoryService:
|
||||
self._open_path(session, existing.id, path_str, now, occ.value)
|
||||
if existing.missing_at is not None:
|
||||
existing.missing_at = None
|
||||
existing.availability_state = availability.ACTIVE
|
||||
existing.state_version += 1
|
||||
existing.updated_at = now
|
||||
return existing, occ
|
||||
@@ -189,6 +209,7 @@ class InventoryService:
|
||||
moved_from.current_path = path_str
|
||||
moved_from.byte_size = size
|
||||
moved_from.missing_at = None
|
||||
moved_from.availability_state = availability.ACTIVE
|
||||
moved_from.state_version += 1
|
||||
moved_from.updated_at = now
|
||||
self._open_path(session, moved_from.id, path_str, now, Occurrence.MOVED.value)
|
||||
|
||||
Reference in New Issue
Block a user