US06-03: Preserve Offline Identity and Review Evidence
This commit is contained in:
124
photo_pipeline/services/availability.py
Normal file
124
photo_pipeline/services/availability.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Where an asset's bytes are right now (US06-03).
|
||||
|
||||
Archiving removes the original from the active library but never removes the
|
||||
asset: its identity, hashes, decisions, and evidence stay. This module is the one
|
||||
place that answers "can these bytes be read, and if not, why" so inventory,
|
||||
duplicate review, thumbnails, and the archive service all give the same answer.
|
||||
|
||||
States (concept §9):
|
||||
|
||||
- ``active`` — the original is in the active library;
|
||||
- ``archived_online`` — the recorded medium is mounted and holds the file;
|
||||
- ``archived_offline`` — archived, but the medium is not available right now;
|
||||
- ``missing_unexpected`` — neither an active path nor the recorded archive
|
||||
location explains the absence. This is the state that must never be confused
|
||||
with ``archived_offline``: an unmounted disk is normal, a mounted disk with a
|
||||
hole in it is not.
|
||||
|
||||
A medium is identified by its marker file, never by its mountpoint, so a
|
||||
different disk mounted at the recorded root is offline rather than accepted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from photo_pipeline.models import ArchiveLocation, Asset
|
||||
|
||||
ACTIVE = "active"
|
||||
ARCHIVED_ONLINE = "archived_online"
|
||||
ARCHIVED_OFFLINE = "archived_offline"
|
||||
MISSING_UNEXPECTED = "missing_unexpected"
|
||||
ARCHIVED = (ARCHIVED_ONLINE, ARCHIVED_OFFLINE)
|
||||
|
||||
MARKER_NAME = ".photo-pipeline-archive.json"
|
||||
|
||||
|
||||
def read_marker(root: Path) -> dict | None:
|
||||
"""The medium's identity marker, or ``None`` when it is not readable."""
|
||||
try:
|
||||
return json.loads((root / MARKER_NAME).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def location_online(location: ArchiveLocation) -> bool:
|
||||
"""True only when the *recorded* medium is mounted at its root."""
|
||||
marker = read_marker(Path(location.root))
|
||||
return bool(marker) and marker.get("media_id") == location.media_id
|
||||
|
||||
|
||||
def archive_file(session: Session, asset: Asset) -> Path | None:
|
||||
"""The archived file's absolute path, whether or not the medium is mounted."""
|
||||
if not asset.archive_location_id or not asset.archive_path:
|
||||
return None
|
||||
location = session.get(ArchiveLocation, asset.archive_location_id)
|
||||
if location is None:
|
||||
return None
|
||||
return Path(location.root) / asset.archive_path
|
||||
|
||||
|
||||
def readable_path(session: Session, asset: Asset) -> Path | None:
|
||||
"""A path whose bytes can be read now: the active file, else the archive copy."""
|
||||
if asset.current_path and Path(asset.current_path).exists():
|
||||
return Path(asset.current_path)
|
||||
archived = archive_file(session, asset)
|
||||
if archived is None:
|
||||
return None
|
||||
location = session.get(ArchiveLocation, asset.archive_location_id)
|
||||
if not location_online(location) or not archived.exists():
|
||||
return None
|
||||
return archived
|
||||
|
||||
|
||||
def state_of(session: Session, asset: Asset, *, online: dict[str, bool] | None = None) -> str:
|
||||
"""The availability this asset's storage actually justifies right now."""
|
||||
if asset.current_path:
|
||||
return ACTIVE if Path(asset.current_path).exists() else MISSING_UNEXPECTED
|
||||
if not asset.archive_location_id:
|
||||
return MISSING_UNEXPECTED if asset.availability_state != ACTIVE else ACTIVE
|
||||
location = session.get(ArchiveLocation, asset.archive_location_id)
|
||||
if location is None:
|
||||
return MISSING_UNEXPECTED
|
||||
reachable = (
|
||||
online[location.id] if online and location.id in online else location_online(location)
|
||||
)
|
||||
if not reachable:
|
||||
return ARCHIVED_OFFLINE
|
||||
archived = archive_file(session, asset)
|
||||
# The medium is mounted and identified: the file is either there, or it is
|
||||
# genuinely gone — that is not "offline", it needs a human.
|
||||
return ARCHIVED_ONLINE if archived and archived.exists() else MISSING_UNEXPECTED
|
||||
|
||||
|
||||
def refresh(session_factory: sessionmaker) -> dict[str, int]:
|
||||
"""Re-derive availability for every archived asset from the media themselves.
|
||||
|
||||
Only archived assets are probed: whether an *active* file is present is the
|
||||
inventory scan's job and costs one stat per library file. Each medium is
|
||||
probed once, not once per asset.
|
||||
"""
|
||||
counts: Counter[str] = Counter()
|
||||
now = datetime.now(timezone.utc)
|
||||
with session_factory() as session:
|
||||
online = {
|
||||
location.id: location_online(location)
|
||||
for location in session.scalars(select(ArchiveLocation))
|
||||
}
|
||||
for asset in session.scalars(
|
||||
select(Asset).where(Asset.archive_location_id.is_not(None))
|
||||
):
|
||||
state = state_of(session, asset, online=online)
|
||||
counts[state] += 1
|
||||
if state != asset.availability_state:
|
||||
asset.availability_state = state
|
||||
asset.state_version += 1
|
||||
asset.updated_at = now
|
||||
session.commit()
|
||||
return dict(counts)
|
||||
Reference in New Issue
Block a user