plan: sub-project 2 — ingest & tagging / review tools

Task-by-task TDD plan implementing the approved sub-project-2 spec: tools/
scaffolding, the additive hef.catalog change (validate_catalog/index_by_id),
ffprobe/ffmpeg mechanical tagging with cover-art guard, per-archive license
normalization, heuristic coordinate drafting, the ingest pipeline + first-ship
fetchers (LibriVox/NASA/Internet Archive, others deferred stubs), the review
transition core + interactive CLI, and hermetic + opt-in integration tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-04 06:14:28 -07:00
parent dd02c2c36a
commit 6d6cbb5cad
@@ -0,0 +1,500 @@
# Ingest & Tagging / Review Tools — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the `tools/` package that turns the hand-authored catalog into the assisted *draft-then-review* pipeline: per-archive ingest fetchers, mechanical tagging (ffprobe/ffmpeg + origin license), heuristic coordinate drafting (`review_status=proposed` + one-line rationale), and an interactive review CLI that flips records to `approved` with a `reviewed_at` stamp.
**Spec:** [`2026-06-04-ingest-tagging-review-tools.md`](../specs/2026-06-04-ingest-tagging-review-tools.md). Read it first — this plan implements it task-by-task and inherits its decisions (esp. §3 the single required `hef.catalog` addition, §5.2 `dominant_color` is optional/opt-in, §6.4 first-ship = LibriVox + NASA + Internet Archive).
**Architecture:** A `tools/` package alongside the existing `hef/` (which it imports, never forks). The two external boundaries — `ffmpeg`/`ffprobe` (subprocess) and archive HTTP — are isolated behind injectable seams (a `Prober`, a `download` callable, an HTTP client) so the unit suite stays **hermetic** (no network, no binaries). Integration tests that actually invoke `ffprobe`/`ffmpeg` are opt-in and skipped when the binaries are absent.
**Tech Stack:** Python 3.11+, stdlib only in the testable core (`dataclasses`, `json`, `subprocess`, `urllib`, `pathlib`, `datetime`); `ffmpeg`/`ffprobe` system binaries; pytest. No `requests`, no image library (dominant color, when computed, is ffmpeg-only).
**Where this sits:** sub-project **2 of 5**. Depends on sub-project 1 (`hef.catalog`, `hef.selection`, built). Feeds sub-project 3 (the Pi player). Sub-project 5 (side walls) was dropped (design §7 revision), so `dominant_color` is optional here.
---
## File Structure
```
pyproject.toml + add `tools` to packages; optional [project.scripts]
.gitignore + media/
hef/catalog.py + validate_catalog(), index_by_id() (ONLY hef change)
tools/__init__.py package marker
tools/http.py stdlib-urllib client (timeout/retry/user-agent), injectable
tools/probe.py Prober: ffprobe wrapper -> parsed Probe result
tools/tagging.py mode/duration_s/resolution from a Probe (+ cover-art guard)
tools/mediatools.py ffmpeg: representative frame; optional dominant_color
tools/licensing.py per-archive license/attribution -> hef LICENSES vocab
tools/drafting.py Signals, Draft, Proposer, HeuristicProposer
tools/ingest/__init__.py
tools/ingest/base.py Candidate, Fetcher, ingest_candidate() pipeline
tools/ingest/librivox.py first-ship fetcher
tools/ingest/nasa.py first-ship fetcher
tools/ingest/internet_archive.py first-ship fetcher
tools/ingest/musopen.py deferred: docstring stub raising NotImplementedError
tools/ingest/fma.py deferred: docstring stub
tools/ingest/freesound.py deferred: docstring stub (needs FREESOUND_API_TOKEN)
tools/ingest_cli.py `python -m tools.ingest_cli`
tools/review.py proposed_records(), approve() (pure transition core)
tools/review_cli.py `python -m tools.review_cli` (interactive walk)
tests/test_catalog_integrity.py validate_catalog / index_by_id
tests/test_probe.py ffprobe JSON parsing (canned)
tests/test_tagging.py mode/duration/resolution + cover-art guard
tests/test_mediatools.py dominant_color from canned rgb bytes; opt-in default off
tests/test_licensing.py per-archive license normalization
tests/test_drafting.py HeuristicProposer
tests/test_fetchers.py librivox/nasa/internet_archive via fake HTTP client
tests/test_ingest_pipeline.py ingest_candidate end-to-end with fakes
tests/test_review.py proposed_records / approve transition
tests/test_tools_integration.py end-to-end mocked; opt-in real-ffprobe (skipped if absent)
```
`tools/` holds everything new; `hef/` gains only the two additive symbols of spec §3.
---
### Task 1: Scaffolding — `tools/` package, deps, http client, smoke test
**Files:** `pyproject.toml`, `.gitignore`, `tools/__init__.py`, `tools/http.py`, `tests/test_tools_smoke.py`
- [ ] **Step 1: Add `tools` to packaging.** In `pyproject.toml`, change `packages = ["hef"]``packages = ["hef", "tools"]`. (Optional: add `[project.scripts]` `hef-ingest = "tools.ingest_cli:main"` and `hef-review = "tools.review_cli:main"` for installed checkouts; `python -m` is the documented path either way.)
- [ ] **Step 2: gitignore media.** Append `media/` to `.gitignore`.
- [ ] **Step 3: Write the failing smoke test** `tests/test_tools_smoke.py`:
```python
def test_tools_package_imports():
import tools
import tools.http
assert tools is not None
```
Run `python -m pytest tests/test_tools_smoke.py -v` → FAIL (`ModuleNotFoundError: tools`).
- [ ] **Step 4: Create `tools/__init__.py`** (empty) and `tools/http.py` — a tiny stdlib client:
```python
"""Minimal HTTP client over urllib: timeout, small retry, user-agent. Injectable."""
from __future__ import annotations
import json as _json
import time
import urllib.request
USER_AGENT = "human-experience-filter-ingest/0.1 (+local art installation)"
class HttpClient:
def __init__(self, *, timeout=30.0, retries=2, opener=None):
self.timeout, self.retries = timeout, retries
self._opener = opener or urllib.request.urlopen
def get_bytes(self, url, *, headers=None):
last = None
for attempt in range(self.retries + 1):
try:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, **(headers or {})})
with self._opener(req, timeout=self.timeout) as resp:
return resp.read()
except Exception as exc: # noqa: BLE001 - retried below
last = exc
if attempt < self.retries:
time.sleep(0.2 * (attempt + 1))
raise last
def get_json(self, url, *, headers=None):
return _json.loads(self.get_bytes(url, headers=headers).decode("utf-8"))
```
The `opener` injection point lets tests pass a fake (no network).
- [ ] **Step 5:** Run smoke test → PASS. Commit:
```
git add pyproject.toml .gitignore tools/__init__.py tools/http.py tests/test_tools_smoke.py
git commit -m "chore: scaffold tools/ package + stdlib http client"
```
---
### Task 2: `hef.catalog` additions — `validate_catalog` + `index_by_id`
The ONLY change to the shared spine (spec §3). Purely additive.
**Files:** `hef/catalog.py`, `tests/test_catalog_integrity.py`
- [ ] **Step 1: Failing tests** `tests/test_catalog_integrity.py` (reuse a local `make_record` like `tests/test_catalog.py`):
```python
import pytest
from hef.catalog import Record, validate_catalog, index_by_id, CatalogError
def make_record(**o):
base = dict(id="a", title="t", source_url="u", source_archive="nasa",
license="public_domain", mode="video", left=0, right=0, dark=0,
light=0, duration_s=1, file_path="p")
base.update(o); return Record(**base)
def test_validate_catalog_accepts_unique_ids():
validate_catalog([make_record(id="a"), make_record(id="b")]) # no raise
def test_validate_catalog_rejects_duplicate_id():
with pytest.raises(CatalogError) as e:
validate_catalog([make_record(id="dup"), make_record(id="dup")])
assert "dup" in str(e.value)
def test_validate_catalog_validates_each_record():
with pytest.raises(CatalogError):
validate_catalog([make_record(left=9)])
def test_index_by_id_round_trips():
recs = [make_record(id="a"), make_record(id="b")]
idx = index_by_id(recs)
assert idx["a"].id == "a" and idx["b"].id == "b"
def test_index_by_id_rejects_duplicates():
with pytest.raises(CatalogError):
index_by_id([make_record(id="x"), make_record(id="x")])
```
Run → FAIL (ImportError).
- [ ] **Step 2: Implement** — append to `hef/catalog.py` (do NOT touch existing functions):
```python
def index_by_id(records) -> dict:
"""Map id -> Record, raising CatalogError on a duplicate id."""
idx: dict = {}
for r in records:
if r.id in idx:
raise CatalogError(f"duplicate record id: {r.id!r}")
idx[r.id] = r
return idx
def validate_catalog(records) -> None:
"""Validate every record AND cross-record invariants (currently: unique ids)."""
for r in records:
validate(r)
index_by_id(records) # raises on duplicate id
```
- [ ] **Step 3:** Run `python -m pytest tests/test_catalog_integrity.py -v` → PASS. Run full suite `python -m pytest -q` → still green (37 prior + new). Commit:
```
git add hef/catalog.py tests/test_catalog_integrity.py
git commit -m "feat: catalog-level validate_catalog + index_by_id (unique ids)"
```
---
### Task 3: `tools/probe.py` — ffprobe wrapper
**Files:** `tools/probe.py`, `tests/test_probe.py`
- [ ] **Step 1: Failing tests** — feed canned `ffprobe` JSON via an injected runner; assert a parsed `Probe` (list of streams + format dict). Cover: a video+audio file, an audio-only file, and an audio file with an `attached_pic` cover stream. Example:
```python
import json
from tools.probe import Probe, probe_file
def fake_runner(args): # mimics subprocess.run(...).stdout
return json.dumps({
"streams": [
{"codec_type": "video", "width": 1920, "height": 1080, "disposition": {"attached_pic": 0}},
{"codec_type": "audio"},
],
"format": {"duration": "12.5"},
})
def test_probe_parses_streams_and_format():
p = probe_file("x.mp4", runner=fake_runner)
assert any(s["codec_type"] == "video" for s in p.streams)
assert p.format["duration"] == "12.5"
```
- [ ] **Step 2: Implement** `tools/probe.py`:
```python
"""ffprobe wrapper -> parsed streams/format. Subprocess runner is injectable."""
from __future__ import annotations
import json, subprocess
from dataclasses import dataclass
@dataclass
class Probe:
streams: list
format: dict
def _default_runner(args) -> str:
return subprocess.run(args, capture_output=True, text=True, check=True).stdout
def probe_file(path, *, runner=_default_runner) -> Probe:
out = runner(["ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", str(path)])
data = json.loads(out)
return Probe(streams=data.get("streams", []), format=data.get("format", {}))
```
- [ ] **Step 3:** Run → PASS. Commit `feat: ffprobe wrapper (tools.probe)`.
---
### Task 4: `tools/tagging.py` — mode / duration_s / resolution (with cover-art guard)
**Files:** `tools/tagging.py`, `tests/test_tagging.py`
- [ ] **Step 1: Failing tests** covering the spec §5.1 cases:
- video+audio → `mode="av"`, `resolution="1920x1080"`, `duration_s=13` (round 12.5).
- audio only → `mode="audio"`, `resolution=""`.
- **audio + `attached_pic` cover image → `mode="audio"`** (the guard), `resolution=""`.
- video only → `mode="video"`.
- duration fallback to longest stream when `format.duration` absent.
```python
from tools.probe import Probe
from tools.tagging import derive_tags
def test_cover_art_stays_audio():
p = Probe(streams=[
{"codec_type": "audio", "duration": "30.0"},
{"codec_type": "video", "width": 600, "height": 600, "disposition": {"attached_pic": 1}},
], format={"duration": "30.0"})
t = derive_tags(p)
assert t.mode == "audio" and t.resolution == "" and t.duration_s == 30
```
- [ ] **Step 2: Implement** — a `Tags(mode, duration_s, resolution)` dataclass and `derive_tags(probe)`:
- real video streams = `codec_type == "video"` AND `disposition.attached_pic != 1`.
- mode: both→`av`, video-only→`video`, else→`audio`.
- resolution from the first real video stream (`f"{w}x{h}"`), else `""`.
- duration: `round(float(format["duration"]))` else max stream duration else 0; never negative.
- [ ] **Step 3:** Run → PASS. Commit `feat: mechanical mode/duration/resolution tagging`.
---
### Task 5: `tools/mediatools.py` — representative frame + optional dominant_color
**Files:** `tools/mediatools.py`, `tests/test_mediatools.py`
- [ ] **Step 1: Failing tests** (hermetic — inject the ffmpeg runner returning canned bytes):
- `dominant_color_from_rgb(b"\xff\x00\x00") == "#ff0000"`.
- `compute_dominant_color(path, runner=fake)` returns the hex when enabled.
- The default ingest path does NOT call this (asserted in Task 8, not here).
- [ ] **Step 2: Implement**:
```python
"""ffmpeg helpers: representative frame; OPTIONAL dominant color. Runner injectable."""
from __future__ import annotations
import subprocess
def _run_bytes(args) -> bytes:
return subprocess.run(args, capture_output=True, check=True).stdout
def dominant_color_from_rgb(rgb: bytes) -> str:
r, g, b = rgb[0], rgb[1], rgb[2]
return f"#{r:02x}{g:02x}{b:02x}"
def compute_dominant_color(path, *, midpoint_s=0.0, runner=_run_bytes) -> str:
"""ffmpeg-only single-color palette of a mid-segment frame -> #rrggbb."""
args = ["ffmpeg", "-v", "quiet", "-ss", str(midpoint_s), "-i", str(path),
"-vf", "thumbnail,palettegen=max_colors=1", "-frames:v", "1",
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"]
return dominant_color_from_rgb(runner(args))
def extract_frame(path, dest, *, midpoint_s=0.0, runner=None):
"""Write one representative frame to dest (PNG) for the review preview."""
runner = runner or (lambda a: subprocess.run(a, check=True))
runner(["ffmpeg", "-v", "quiet", "-y", "-ss", str(midpoint_s), "-i", str(path),
"-frames:v", "1", str(dest)])
return dest
```
- [ ] **Step 3:** Run → PASS. Commit `feat: ffmpeg frame extraction + optional dominant_color`.
---
### Task 6: `tools/licensing.py` — origin → license/attribution
**Files:** `tools/licensing.py`, `tests/test_licensing.py`
- [ ] **Step 1: Failing tests** mapping sample origin metadata per archive to the `hef.catalog.LICENSES` vocab:
- CC-BY url/identifier → `("cc_by", "<attribution string>")` (non-empty attribution).
- CC0 / public-domain markers → `("cc0"|"public_domain", "")`.
- LibriVox → always `("public_domain", "")`.
- unmappable → raises a clear error (rejected at ingest).
```python
from tools.licensing import normalize_license
def test_cc_by_requires_attribution():
lic, attr = normalize_license("https://creativecommons.org/licenses/by/4.0/",
creator="Jane Doe")
assert lic == "cc_by" and "Jane Doe" in attr
def test_unmappable_rejected():
import pytest
with pytest.raises(ValueError):
normalize_license("All Rights Reserved")
```
- [ ] **Step 2: Implement** `normalize_license(raw, *, creator="", license_name="")`:
- regex/string-match CC URLs and identifiers → `cc0`/`cc_by`/`cc_by_nc`.
- public-domain / "no known copyright" / "publicdomain" → `public_domain`.
- for `ATTRIBUTION_LICENSES` build `attribution` from `creator` + license name/URL (must be non-empty; `validate()` enforces it downstream).
- anything else → `raise ValueError(f"unmappable license: {raw!r}")`.
- Per-archive helpers may wrap it (e.g. `librivox_license()` returns `("public_domain","")`).
- [ ] **Step 3:** Run → PASS. Commit `feat: per-archive license normalization`.
---
### Task 7: `tools/drafting.py` — heuristic coordinate proposer
**Files:** `tools/drafting.py`, `tests/test_drafting.py`
- [ ] **Step 1: Failing tests** for `HeuristicProposer` (spec §7.1):
- `librivox` archive → high `left`; `nasa`/`musopen`/`fma` → high `right`.
- title/description containing a dark keyword ("storm") raises `dark`; a light keyword ("sunrise") raises `light`.
- all coords clamped to 0..4.
- `rationale` is a non-empty single line citing a signal.
```python
from tools.drafting import Signals, HeuristicProposer
def test_librivox_seeds_left():
d = HeuristicProposer().propose(Signals(title="Meditations", description="",
source_archive="librivox", mode="audio", duration_s=600))
assert d.coordinate.left >= 3 and d.rationale
def test_storm_seeds_dark():
d = HeuristicProposer().propose(Signals(title="Thunderstorm at Night",
description="", source_archive="nasa", mode="video", duration_s=600))
assert d.coordinate.dark >= 2
```
- [ ] **Step 2: Implement** `Signals`, `Draft` (wrapping `hef.selection.Coordinate`), `Proposer` protocol, and `HeuristicProposer` with: archive priors (brain plane), keyword sets for dark/light (mood plane), `max(0, min(4, v))` clamping, one-line rationale naming the dominant signal. Deterministic — no I/O.
- [ ] **Step 3:** Run → PASS. Commit `feat: heuristic coordinate proposer (drafting)`.
---
### Task 8: `tools/ingest/base.py` — Candidate, Fetcher, pipeline
**Files:** `tools/ingest/__init__.py`, `tools/ingest/base.py`, `tests/test_ingest_pipeline.py`
- [ ] **Step 1: Failing tests** for `ingest_candidate` with ALL boundaries faked (no network, no ffprobe, no disk writes beyond tmp_path):
- given a `Candidate` + fake `prober` (returns a Probe) + fake `downloader` (writes a tmp file) + `HeuristicProposer`, it appends ONE `proposed` record whose mechanical fields match the probe, `review_status=="proposed"`, `reviewed_at is None`, `rationale` non-empty, `file_path` relative to media-root.
- re-running with the same `suggested_id` is idempotent (no duplicate; skipped).
- `dominant_color` stays `""` by default; computed only when `compute_color=True`.
- a candidate whose license is unmappable raises before writing (nothing appended).
- [ ] **Step 2: Implement** `Candidate` (spec §6.1), the `Fetcher` Protocol (`archive`, `search`, `resolve`), and:
```python
def ingest_candidate(candidate, *, catalog_path, media_root, proposer,
prober=probe_file, downloader=..., compute_color=False):
# 1. load_catalog + validate_catalog; skip if candidate.suggested_id exists
# 2. download media_url -> <media_root>/<archive>/<id>.<ext> (skip if present)
# 3. tags = derive_tags(prober(file)); color = compute_dominant_color(...) if compute_color and video
# 4. draft = proposer.propose(Signals(...))
# 5. build hef.catalog.Record(...) (proposed shape via defaults)
# 6. validate(record); re-check uniqueness; append_record(record, catalog_path)
```
Plus `ingest_search(fetcher, query, *, limit, **kw)` looping the above over `fetcher.search`.
- [ ] **Step 3:** Run → PASS. Commit `feat: ingest pipeline (Candidate/Fetcher/ingest_candidate)`.
---
### Task 9: First-ship fetchers — LibriVox, NASA, Internet Archive
**Files:** `tools/ingest/librivox.py`, `tools/ingest/nasa.py`, `tools/ingest/internet_archive.py`, `tools/ingest/{musopen,fma,freesound}.py` (deferred stubs), `tests/test_fetchers.py`
- [ ] **Step 1: Failing tests** — one per first-ship fetcher, feeding canned API JSON through `HttpClient(opener=fake)`; assert `Candidate` fields + a stable `suggested_id` derivation and the normalized license:
- **LibriVox** (`librivox.org/api/feed/audiobooks?...&format=json`): `license="public_domain"`, `mode` hint audio, id like `librivox-<slug>`.
- **NASA** (`images-api.nasa.gov/search?q=...`): `license="public_domain"`, id like `nasa-<nasa_id>`, media_url from the asset collection.
- **Internet Archive** (`archive.org/metadata/<id>`): license from `licenseurl`/`rights` via `tools.licensing`; ambiguous "no known copyright" → `public_domain` with a `notes` flag; id like `ia-<identifier>`.
- [ ] **Step 2: Implement** the three fetchers against the documented JSON APIs, each taking an injected `HttpClient`, each exposing `archive`, `search(query, limit)`, `resolve(identifier)`. Use `tools.licensing` for license/attribution.
- [ ] **Step 3: Deferred stubs.** `musopen.py`, `fma.py`, `freesound.py` define the class with the `Fetcher` shape but `raise NotImplementedError("deferred — see spec §6.4")`; `freesound.py` documents the `FREESOUND_API_TOKEN` env requirement (secret; never logged). A test asserts they raise `NotImplementedError` (so the seam is wired and the deferral is explicit, not forgotten).
- [ ] **Step 4:** Run → PASS. Commit `feat: LibriVox/NASA/Internet Archive fetchers (+ deferred stubs)`.
---
### Task 10: `tools/ingest_cli.py`
**Files:** `tools/ingest_cli.py` (light/manual test)
- [ ] **Step 1:** `argparse` entry: `python -m tools.ingest_cli <archive> --query "..." [--limit N] [--catalog catalog/library.jsonl] [--media-root ./media] [--dominant-color] [--resolve <identifier>]`. Wires the named fetcher + `HeuristicProposer` into `ingest_search`/`ingest_candidate`. `main()` returns an exit code; secrets read from env only.
- [ ] **Step 2:** A small test that `--help` parses and that an unknown archive errors cleanly (no network). Real fetches are manual/integration.
- [ ] **Step 3:** Commit `feat: ingest CLI entry point`.
---
### Task 11: `tools/review.py` — the transition core
**Files:** `tools/review.py`, `tests/test_review.py`
- [ ] **Step 1: Failing tests** (spec §8.1):
```python
from dataclasses import replace
from tools.review import proposed_records, approve
from hef.selection import Coordinate
# make_record helper as elsewhere
def test_proposed_records_filters():
a = make_record(id="a", review_status="proposed")
b = make_record(id="b", review_status="approved")
assert [r.id for r in proposed_records([a, b])] == ["a"]
def test_approve_sets_status_and_timestamp():
r = make_record(review_status="proposed")
out = approve(r, reviewed_at="2026-06-04T13:00:00+00:00")
assert out.review_status == "approved" and out.reviewed_at == "2026-06-04T13:00:00+00:00"
def test_approve_can_override_coordinates():
r = make_record(left=0, right=0, dark=0, light=0, review_status="proposed")
out = approve(r, reviewed_at="t", coordinate=Coordinate(4, 1, 2, 3))
assert (out.left, out.right, out.dark, out.light) == (4, 1, 2, 3)
```
- [ ] **Step 2: Implement** `proposed_records(records)` and `approve(record, *, reviewed_at, coordinate=None)` using `dataclasses.replace` to return an approved copy (no in-place surprise); caller re-validates. Pure, no I/O, no clock (timestamp injected).
- [ ] **Step 3:** Run → PASS. Commit `feat: review transition core (proposed -> approved)`.
---
### Task 12: `tools/review_cli.py` — interactive walk
**Files:** `tools/review_cli.py` (manual; preview reuses `tools.mediatools`)
- [ ] **Step 1:** Implement the walk (spec §8.2): `load_catalog``validate_catalog`; for each `proposed` record print id/title/source/license/mode/duration/resolution + proposed coords + rationale; render a preview (video/av → `extract_frame` then `open`/`xdg-open`; audio → `showwavespic` thumbnail and/or optional `ffplay`); prompt `[a]ccept/[e]dit/[s]kip/[q]uit`; on accept/edit call `approve(...)` with `datetime.now(timezone.utc).isoformat()` and persist via `save_catalog` (rewrite) after each approval. `--catalog`, `--media-root` args.
- [ ] **Step 2:** Keep the shell thin — all decision logic already tested in Task 11. A tiny test that `--help` parses; the interactive loop is manual.
- [ ] **Step 3:** Commit `feat: interactive review CLI`.
---
### Task 13: End-to-end mocked integration + opt-in real-ffprobe
**Files:** `tests/test_tools_integration.py`
- [ ] **Step 1: Hermetic end-to-end** (spec §10 test 8): fake fetcher → `Candidate`; fake downloader + fake prober + `HeuristicProposer``ingest_candidate` appends a `proposed` record; `proposed_records` + `approve` flips it; `load_catalog` + `validate_catalog` pass; `select(loaded, coord, mode, approved_only=True)` returns it.
- [ ] **Step 2: Opt-in real-ffprobe test** — guarded by `shutil.which("ffprobe")` (`pytest.mark.skipif` when absent): generate a 1-second test clip with `ffmpeg lavfi` into `tmp_path`, probe it, assert `mode`/`duration_s`/`resolution`. Same pattern for a real `compute_dominant_color`.
- [ ] **Step 3:** Run the FULL suite `python -m pytest -q` → green. Commit `test: end-to-end ingest+review integration (+ opt-in ffprobe)`.
---
### Task 14: Docs — extend the User Guide for the tools
**Files:** `docs/USER_GUIDE.md`
- [ ] **Step 1:** Add an "Ingesting & reviewing media" section: prerequisites (`ffmpeg`/`ffprobe`), `--media-root`, the first-ship archives, `python -m tools.ingest_cli ...` examples, the review walk, the Freesound token note (env only), and that `dominant_color` is optional/opt-in. Update the scope banner (no longer "only catalog core is built"). Reconcile the absolute-vs-relative `file_path` note (spec §6.3).
- [ ] **Step 2:** Commit `docs: user guide for ingest + review tools`.
---
## Done criteria
- `python -m pytest -q` is green from the repo root inside `.venv` (prior 37 + all new unit tests; opt-in ffprobe tests skip cleanly when binaries are absent).
- `hef.catalog` gains exactly `validate_catalog` and `index_by_id`; every pre-existing symbol/behavior is unchanged.
- `python -m tools.ingest_cli <archive> --query ...` against LibriVox / NASA / Internet Archive produces `proposed` records with mechanical fields filled and a one-line rationale.
- `python -m tools.review_cli` walks the `proposed` records (coords + rationale + preview frame) and flips accepted ones to `approved` with a `reviewed_at` stamp; `load_catalog` + `validate_catalog` validate the result.
- `dominant_color` is computed only with `--dominant-color`; Freesound/Musopen/FMA fetchers are explicit deferred stubs (raise `NotImplementedError`).
This populated, human-reviewed catalog is what sub-project 3 (the Pi player) drives to the single panoramic projector.