Files

211 lines
7.5 KiB
Python

"""Audit per-thread media manifest: local integrity, note refs, mirror freshness, origin probes."""
from __future__ import annotations
import json
import sys
from collections import Counter
from dataclasses import dataclass, replace
from http.client import HTTPResponse
from pathlib import Path
from typing import Callable
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
try:
from .frozen import frozen_match
from .media_manifest import (
_from_wire_dict,
find_location,
hash_file,
inventory_digest,
item_key,
payload_inventory,
selected_url,
validate_manifest,
)
from .media_refs import remote_markup
from .models import LegacyMediaJson, MediaItem, MediaLocation, MediaLocationCheck, MediaManifest, OriginCheck
except ImportError: # pragma: no cover - script-mode import
if str(Path(__file__).resolve().parent) not in sys.path:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from frozen import frozen_match
from media_manifest import (
_from_wire_dict,
find_location,
hash_file,
inventory_digest,
item_key,
payload_inventory,
selected_url,
validate_manifest,
)
from media_refs import remote_markup
from models import (
LegacyMediaJson,
MediaItem,
MediaLocation,
MediaLocationCheck,
MediaManifest,
OriginCheck,
)
@dataclass(frozen=True)
class AuditReport:
"""Outcome of a per-thread media audit: frozen flag plus blocking issues."""
frozen: bool
issues: tuple[str, ...]
def audit_local_item(item: MediaItem, asset_dir: Path) -> str | None:
local = find_location(item, "local")
if local is None:
return f"{'/'.join(item_key(item))} missing local location"
filename = str(local.local_path) if local.local_path is not None else ""
path = asset_dir / filename
if not path.is_file():
return f"{'/'.join(item_key(item))} local missing"
sha_expected = local.sha256 or ""
bytes_expected = local.bytes
if bytes_expected is not None and path.stat().st_size != bytes_expected:
return f"{'/'.join(item_key(item))} local mismatch"
if hash_file(path) != sha_expected:
return f"{'/'.join(item_key(item))} local mismatch"
return None
def classify_origin_response(status: int | None, detail: str, checked_at: str) -> OriginCheck:
"""Build an ``OriginCheck`` from an HTTP probe status + detail string.
The detail string is truncated to 200 chars so the persisted check
does not bloat the manifest. ``confirms_unavailable`` is always False
here; callers that detect a definitive unavailability set it True.
"""
available = status is not None and 200 <= status < 400
return OriginCheck(
checked_at=checked_at,
status=status,
result="available" if available else "error",
detail=detail[:200],
confirms_unavailable=False,
)
def check_origin_url(
url: str,
checked_at: str,
opener: Callable[..., HTTPResponse] = urlopen,
) -> OriginCheck:
"""Probe ``url`` with HEAD and return a typed ``OriginCheck``.
``opener`` is variadic because ``urlopen`` accepts both ``url`` and
``Request`` shapes with different keyword argument sets; callers
passing a custom opener can match either.
"""
request = Request(url, method="HEAD", headers={"User-Agent": "Threadwell-media-audit/1"})
try:
with opener(request, timeout=30) as response:
return classify_origin_response(
int(response.status),
"HEAD completed",
checked_at,
)
except HTTPError as exc:
return classify_origin_response(exc.code, f"HTTP {exc.code}", checked_at)
except (URLError, TimeoutError, OSError) as exc:
return classify_origin_response(None, type(exc).__name__, checked_at)
def record_origin_check(
manifest: LegacyMediaJson, item_index: int, outcome: OriginCheck
) -> LegacyMediaJson:
"""Return a new ``LegacyMediaJson`` with the origin check at ``item_index`` persisted.
The input ``LegacyMediaJson`` is frozen, so the update is a
round-trip: each affected ``MediaItem`` is reconstructed with the
updated ``MediaLocation`` for ``origin:x`` carrying the check
metadata. Returns the new manifest; the caller is responsible for
serializing it back to disk.
"""
items = list(manifest.items)
if not (0 <= item_index < len(items)):
raise ValueError("item_index out of range")
item = items[item_index]
new_locations: list[MediaLocation] = []
found_origin = False
for location in item.locations:
if location.id == "origin:x":
found_origin = True
new_availability = (
"available"
if outcome.result == "available"
else location.availability
)
new_locations.append(
replace(
location,
availability=new_availability,
checked_at=outcome.checked_at,
checked_status=outcome.status,
check=MediaLocationCheck(
status=outcome.status,
result=outcome.result,
detail=outcome.detail,
),
)
)
else:
new_locations.append(location)
if not found_origin:
raise ValueError("item has no origin:x location")
items[item_index] = replace(item, locations=tuple(new_locations))
return replace(manifest, items=tuple(items))
def audit_thread(
asset_dir: Path,
note_dir: Path,
frozen_ids: set[str],
) -> AuditReport:
"""Audit a single thread's media manifest + note references.
Reads ``media.json`` at the boundary into ``LegacyMediaJson`` (for the
opaque mirrors blob) and a typed ``MediaManifest`` (for item /
location validation). Reports issues with the local file presence,
note reference counts, and mirror staleness.
"""
match = frozen_match(asset_dir, frozen_ids)
if match is not None:
return AuditReport(True, (f"frozen: skipped ({match})",))
manifest_dict = json.loads((asset_dir / "media.json").read_text(encoding="utf-8"))
legacy = LegacyMediaJson.from_dict(manifest_dict)
manifest = _from_wire_dict(manifest_dict)
issues: list[str] = list(validate_manifest(manifest))
for item in manifest.items:
if find_location(item, "local") is not None:
issue = audit_local_item(item, asset_dir)
if issue is not None:
issues.append(issue)
expected = Counter(
remote_markup(url or "")
for item in manifest.items
if item.embed and (url := selected_url(item)) is not None
)
note_text = "\n".join(
path.read_text(encoding="utf-8")
for path in sorted(note_dir.glob("*.md"))
)
for markup, count in expected.items():
actual = note_text.count(markup)
if actual != count:
issues.append(
f"selected reference count mismatch: {markup} expected={count} actual={actual}"
)
current_digest = inventory_digest(payload_inventory(asset_dir, manifest))
for mirror in (legacy.mirrors or {}).values():
if mirror.get("state") == "synced" and mirror.get("inventory_digest") != current_digest:
issues.append(f"mirror stale: {mirror.get('destination_id')}")
return AuditReport(False, tuple(sorted(set(issues))))