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:
@@ -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.
|
||||
Reference in New Issue
Block a user