125 lines
4.9 KiB
Python
125 lines
4.9 KiB
Python
"""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)
|