"""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)