docs: define portable audiobook TUI

This commit is contained in:
drockhollaback
2026-08-26 19:43:49 -04:00
commit 74657f4742
3 changed files with 1040 additions and 0 deletions
@@ -0,0 +1,777 @@
# Portable Audiobook TUI 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 a copyable Docker Compose project that interactively converts pending MP3 audiobooks to validated M4B files and moves sources through filesystem workflow states.
**Architecture:** One ephemeral Python terminal application runs inside an image derived from pinned `sandreas/m4b-tool`. Focused modules scan and probe sources, build metadata and chapters, construct shell-free commands, execute and validate conversion, then finalize filesystem state only after typed approval.
**Tech Stack:** Python 3.12, Rich 14.2.0, prompt-toolkit 3.0.52, HTTPX 0.28.1, pytest 8.4.2, pytest-asyncio 1.1.0, Docker Compose, `m4b-tool`, `ffmpeg`, `ffprobe`.
**Spec:** `docs/superpowers/specs/2026-08-26-audiobook-tui-design.md`
## Global Constraints
- Headless SSH terminal only; no browser, desktop GUI, or mouse requirement.
- Default host roots are `/mnt/riker/alexandria/process/audiobook/`, `/mnt/riker/alexandria/process/audiobook-ready/`, `/mnt/riker/alexandria/process/audiobook-done/`, and `/mnt/riker/alexandria/process/audiobook-review/`.
- MP3 is required input type; output is one M4B per selected book.
- `m4b-tool` performs merge/conversion; bundled `ffmpeg` may apply manual chapter metadata afterward.
- Audnexus remains optional, public, unauthenticated, timeout-limited, and failure-safe.
- Conversion requires exact `CONVERT`; review move requires exact `REVIEW`.
- Existing ready output is never overwritten.
- Source remains pending until output validation and finalization both succeed.
- Commands execute as argument vectors without a shell.
- Tests contain only generated synthetic media.
## File Map
- `pyproject.toml`: package metadata, locked direct dependencies, pytest settings, console entry point.
- `app/models.py`: immutable domain records and enums.
- `app/config.py`: TOML/environment loading and startup validation.
- `app/scanner.py`: queue discovery, natural sorting, `ffprobe` execution, media normalization.
- `app/chapters.py`: embedded, filename, and manual chapter generation and validation.
- `app/metadata.py`: local metadata inference and safe filename normalization.
- `app/audnexus.py`: public API search/book client and untrusted response validation.
- `app/planner.py`: contained path resolution and conversion argument vectors.
- `app/converter.py`: subprocess lifecycle, logs, interruption, output probing and validation.
- `app/workflow.py`: partial finalization, done/review moves, stale artifact handling.
- `app/tui.py`: interactive queue-to-confirmation flow.
- `app/main.py`: composition root and CLI entry point.
- `tests/`: unit and integration coverage mirroring modules.
- `Dockerfile`, `compose.yaml`, `.env.example`, `config.toml`: portable runtime.
- `scripts/entrypoint.sh`: numeric UID/GID setup and privilege drop.
- `scripts/container-smoke.sh`: image dependency and synthetic conversion check.
- `README.md`, `LICENSE`: deployment, operation, recovery, and project license.
---
### Task 1: Package Skeleton, Domain Types, and Configuration
**Files:**
- Create: `pyproject.toml`
- Create: `app/__init__.py`
- Create: `app/models.py`
- Create: `app/config.py`
- Create: `tests/test_config.py`
**Interfaces:**
- Produces: `ChapterMode`, `Track`, `Chapter`, `Book`, `BookMetadata`, `ConversionPlan`, `Roots`, `Settings`.
- Produces: `load_settings(path: Path, environ: Mapping[str, str]) -> Settings`.
- Produces: `Settings.validate() -> None`, raising `ConfigError` with all startup errors.
- [ ] **Step 1: Create package metadata and failing configuration tests**
```toml
[build-system]
requires = ["setuptools==80.9.0"]
build-backend = "setuptools.build_meta"
[project]
name = "audiobook-tui"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"httpx==0.28.1",
"prompt-toolkit==3.0.52",
"rich==14.2.0",
]
[project.optional-dependencies]
test = ["pytest==8.4.2", "pytest-asyncio==1.1.0"]
[project.scripts]
audiobook-tui = "app.main:main"
[tool.pytest.ini_options]
addopts = "-ra"
testpaths = ["tests"]
```
```python
# tests/test_config.py
from pathlib import Path
import pytest
from app.config import ConfigError, load_settings
def test_environment_overrides_all_host_roots(tmp_path: Path) -> None:
config = tmp_path / "config.toml"
config.write_text('[paths]\npending="/data/pending"\nready="/data/ready"\ndone="/data/done"\nreview="/data/review"\n', encoding="utf-8")
settings = load_settings(config, {"AUDIOBOOK_PENDING": "/custom/pending"})
assert settings.roots.pending == Path("/custom/pending")
assert settings.roots.ready == Path("/data/ready")
def test_validation_reports_duplicate_roots_and_invalid_jobs(tmp_path: Path) -> None:
config = tmp_path / "config.toml"
config.write_text('[paths]\npending="/same"\nready="/same"\ndone="/done"\nreview="/review"\n[conversion]\njobs=0\n', encoding="utf-8")
with pytest.raises(ConfigError) as caught:
load_settings(config, {})
assert caught.value.errors == ("configured roots must be distinct", "conversion.jobs must be at least 1")
```
- [ ] **Step 2: Run tests and verify RED**
Run: `python -m pytest tests/test_config.py -v`
Expected: collection fails with `ModuleNotFoundError: No module named 'app.config'`.
- [ ] **Step 3: Implement immutable models and configuration loader**
```python
# app/models.py
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
class ChapterMode(StrEnum):
EMBEDDED = "embedded"
FILENAMES = "filenames"
MANUAL = "manual"
@dataclass(frozen=True)
class Track:
path: Path
duration_ms: int
title: str | None = None
track_number: int | None = None
disc_number: int | None = None
@dataclass(frozen=True)
class Chapter:
start_ms: int
title: str
@dataclass(frozen=True)
class Book:
source: Path
tracks: tuple[Track, ...]
@dataclass(frozen=True)
class BookMetadata:
title: str
author: str
narrator: str = ""
series: str = ""
series_position: str = ""
year: str = ""
description: str = ""
cover: Path | None = None
@dataclass(frozen=True)
class ConversionPlan:
book: Book
metadata: BookMetadata
chapters: tuple[Chapter, ...]
output: Path
partial_output: Path
argv: tuple[str, ...]
@dataclass(frozen=True)
class Roots:
pending: Path
ready: Path
done: Path
review: Path
@dataclass(frozen=True)
class Settings:
roots: Roots
state_dir: Path = Path("/app/state")
work_dir: Path = Path("/tmp/audiobook-tui")
jobs: int = 2
audnexus_enabled: bool = True
audnexus_base_url: str = "https://api.audnex.us"
audnexus_connect_timeout: float = 3.0
audnexus_read_timeout: float = 8.0
duration_tolerance_ms: int = 3000
```
Implement `ConfigError(errors: tuple[str, ...])`, TOML parsing with `tomllib`, explicit environment override names, type checks, distinct roots, positive timeouts/jobs, and HTTPS requirement for Audnexus URL.
- [ ] **Step 4: Run tests and verify GREEN**
Run: `python -m pytest tests/test_config.py -v`
Expected: `2 passed`.
- [ ] **Step 5: Commit**
```bash
git add pyproject.toml app/__init__.py app/models.py app/config.py tests/test_config.py
git commit -m "feat: add audiobook domain model and configuration"
```
### Task 2: Queue Discovery, Natural Sorting, and Media Probing
**Files:**
- Create: `app/scanner.py`
- Create: `tests/test_scanner.py`
- Create: `tests/helpers.py`
**Interfaces:**
- Consumes: `Book`, `Track`, `Roots` from `app.models`.
- Produces: `natural_key(value: str) -> tuple[object, ...]`.
- Produces: `discover_pending(root: Path) -> tuple[Path, ...]`.
- Produces: `probe_track(path: Path, runner: ProbeRunner) -> Track`.
- Produces: `scan_book(source: Path, runner: ProbeRunner) -> Book`.
- `ProbeRunner` signature: `Callable[[tuple[str, ...]], subprocess.CompletedProcess[str]]`.
- [ ] **Step 1: Write failing discovery and ordering tests**
```python
# tests/test_scanner.py
from pathlib import Path
from app.scanner import discover_pending, natural_key
def test_natural_sort_orders_numbered_tracks() -> None:
names = ["Track 10.mp3", "track 2.mp3", "Track 01.mp3"]
assert sorted(names, key=natural_key) == ["Track 01.mp3", "track 2.mp3", "Track 10.mp3"]
def test_discovery_returns_book_directories_and_loose_mp3(tmp_path: Path) -> None:
(tmp_path / "Book 10").mkdir()
(tmp_path / "Book 10" / "01.mp3").touch()
(tmp_path / "Book 2.mp3").touch()
(tmp_path / ".hidden.mp3").touch()
(tmp_path / "notes.txt").touch()
assert [p.name for p in discover_pending(tmp_path)] == ["Book 2.mp3", "Book 10"]
```
- [ ] **Step 2: Run tests and verify RED**
Run: `python -m pytest tests/test_scanner.py -v`
Expected: import fails because `app.scanner` does not exist.
- [ ] **Step 3: Implement natural sorting and queue discovery**
Use `re.split(r"(\d+)", value)` and return lowercase text tokens, integer numeric tokens, plus original value as stable fallback. Include only non-hidden loose `.mp3` files and immediate directories containing at least one recursive non-hidden `.mp3`.
- [ ] **Step 4: Run discovery tests and verify GREEN**
Run: `python -m pytest tests/test_scanner.py -v`
Expected: `2 passed`.
- [ ] **Step 5: Add failing probe and book scan tests**
```python
import json
import subprocess
from app.scanner import ScanError, scan_book
def test_scan_book_normalizes_ffprobe_tags_and_duration(tmp_path: Path) -> None:
source = tmp_path / "Book"
source.mkdir()
(source / "02.mp3").touch()
(source / "01.mp3").touch()
def runner(argv: tuple[str, ...]) -> subprocess.CompletedProcess[str]:
payload = {"format": {"duration": "61.250", "tags": {"title": Path(argv[-1]).stem, "track": "1/8", "disc": "2/2"}}}
return subprocess.CompletedProcess(argv, 0, json.dumps(payload), "")
book = scan_book(source, runner)
assert [track.path.name for track in book.tracks] == ["01.mp3", "02.mp3"]
assert book.tracks[0].duration_ms == 61250
assert book.tracks[0].track_number == 1
assert book.tracks[0].disc_number == 2
def test_scan_book_rejects_unprobeable_track(tmp_path: Path) -> None:
source = tmp_path / "bad.mp3"
source.touch()
def runner(argv: tuple[str, ...]) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(argv, 1, "", "invalid data")
with pytest.raises(ScanError, match="invalid data"):
scan_book(source, runner)
```
- [ ] **Step 6: Run probe tests and verify RED**
Run: `python -m pytest tests/test_scanner.py -v`
Expected: fails because `scan_book` and `ScanError` are missing.
- [ ] **Step 7: Implement probing and scanning**
Build exact probe vector `("ffprobe", "-v", "error", "-show_entries", "format=duration:format_tags=title,track,disc", "-of", "json", str(path))`. Reject nonzero exit, malformed JSON, missing/nonpositive duration, and empty books. Parse leading integer from `1/8`; preserve missing title as `None`.
- [ ] **Step 8: Run scanner suite and commit**
Run: `python -m pytest tests/test_scanner.py -v`
Expected: `4 passed`.
```bash
git add app/scanner.py tests/test_scanner.py tests/helpers.py
git commit -m "feat: discover and probe pending audiobooks"
```
### Task 3: Chapters, Local Metadata, and Audnexus
**Files:**
- Create: `app/chapters.py`
- Create: `app/metadata.py`
- Create: `app/audnexus.py`
- Create: `tests/test_chapters.py`
- Create: `tests/test_metadata.py`
- Create: `tests/test_audnexus.py`
**Interfaces:**
- Consumes: `Book`, `BookMetadata`, `Chapter`, `Track`.
- Produces: `chapters_from_embedded(book: Book) -> tuple[Chapter, ...]`.
- Produces: `chapters_from_filenames(book: Book) -> tuple[Chapter, ...]`.
- Produces: `parse_chapters(path: Path, total_duration_ms: int) -> tuple[Chapter, ...]`.
- Produces: `infer_metadata(book: Book) -> BookMetadata`.
- Produces: `safe_output_stem(value: str) -> str`.
- Produces: `AudnexusClient.search(query: str) -> tuple[AudnexusBook, ...]` and `close() -> None`.
- [ ] **Step 1: Write failing chapter tests**
```python
# tests/test_chapters.py
from pathlib import Path
import pytest
from app.chapters import ChapterError, chapters_from_filenames, parse_chapters
from app.models import Book, Track
def test_filename_chapters_use_track_boundaries_and_clean_prefixes(tmp_path: Path) -> None:
tracks = (Track(tmp_path / "01 - Opening.mp3", 1000), Track(tmp_path / "002.Chapter Two.mp3", 2500))
assert [(c.start_ms, c.title) for c in chapters_from_filenames(Book(tmp_path, tracks))] == [(0, "Opening"), (1000, "Chapter Two")]
def test_manual_chapters_accept_milliseconds(tmp_path: Path) -> None:
source = tmp_path / "chapters.txt"
source.write_text("00:00:00 Opening\n00:01:42.500 Chapter 1\n", encoding="utf-8")
assert [(c.start_ms, c.title) for c in parse_chapters(source, 200000)] == [(0, "Opening"), (102500, "Chapter 1")]
@pytest.mark.parametrize("text,message", [
("00:00:01 Late start\n", "first chapter must start at 00:00:00"),
("00:00:00 One\n00:00:00 Two\n", "timestamps must be strictly increasing"),
("00:00:00 One\n00:03:20 Two\n", "chapter timestamp must precede total duration"),
("nonsense\n", "line 1 has invalid chapter syntax"),
])
def test_manual_chapters_reject_invalid_input(tmp_path: Path, text: str, message: str) -> None:
source = tmp_path / "chapters.txt"
source.write_text(text, encoding="utf-8")
with pytest.raises(ChapterError, match=message):
parse_chapters(source, 200000)
```
- [ ] **Step 2: Run chapter tests and verify RED**
Run: `python -m pytest tests/test_chapters.py -v`
Expected: import fails because `app.chapters` does not exist.
- [ ] **Step 3: Implement chapter builders and parser**
Use cumulative track durations for file-boundary chapters. Embedded mode raises `ChapterError` listing every track missing a title. Filename cleaning removes one leading sequence matching `^\s*\d+\s*[-._)]\s*`. Manual parser decodes UTF-8 strictly and validates every rule before returning data.
- [ ] **Step 4: Run chapter tests and verify GREEN**
Run: `python -m pytest tests/test_chapters.py -v`
Expected: all chapter tests pass.
- [ ] **Step 5: Write failing metadata and Audnexus tests**
```python
# tests/test_metadata.py
from app.metadata import infer_metadata, safe_output_stem
def test_safe_output_stem_removes_path_and_control_characters() -> None:
assert safe_output_stem("../Bad\x00 / Title") == "Bad - Title"
def test_folder_inference_reads_author_dash_title(book_factory) -> None:
metadata = infer_metadata(book_factory("Ursula Le Guin - The Dispossessed"))
assert (metadata.author, metadata.title) == ("Ursula Le Guin", "The Dispossessed")
```
```python
# tests/test_audnexus.py
import httpx
from app.audnexus import AudnexusClient
def test_search_validates_and_normalizes_public_results() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.headers.get("authorization") is None
return httpx.Response(200, json=[{"asin": "B01", "name": "Book", "authors": [{"name": "Author"}], "narrators": [{"name": "Narrator"}]}])
client = AudnexusClient("https://api.audnex.us", transport=httpx.MockTransport(handler))
assert client.search("Book")[0].author == "Author"
def test_timeout_returns_empty_results() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("late", request=request)
client = AudnexusClient("https://api.audnex.us", transport=httpx.MockTransport(handler))
assert client.search("Book") == ()
```
- [ ] **Step 6: Run metadata/API tests and verify RED**
Run: `python -m pytest tests/test_metadata.py tests/test_audnexus.py -v`
Expected: imports fail because modules do not exist.
- [ ] **Step 7: Implement metadata inference and bounded API client**
Define immutable `AudnexusBook(asin, title, author, narrator, description, cover_url)`. Search uses `GET /search` with `q`, `region=us`, `minScore=0`; set `User-Agent: audiobook-tui/0.1.0`. Accept list or documented result wrapper, cap strings at 10,000 characters, cap results at 20, ignore invalid records, and return empty tuple for HTTP, timeout, decode, or schema errors. Never add authentication headers.
- [ ] **Step 8: Run task suite and commit**
Run: `python -m pytest tests/test_chapters.py tests/test_metadata.py tests/test_audnexus.py -v`
Expected: all tests pass.
```bash
git add app/chapters.py app/metadata.py app/audnexus.py tests/test_chapters.py tests/test_metadata.py tests/test_audnexus.py
git commit -m "feat: build chapters and optional Audnexus metadata"
```
### Task 4: Safe Planning, Conversion, Validation, and State Transitions
**Files:**
- Create: `app/planner.py`
- Create: `app/converter.py`
- Create: `app/workflow.py`
- Create: `tests/test_planner.py`
- Create: `tests/test_converter.py`
- Create: `tests/test_workflow.py`
**Interfaces:**
- Consumes: `Book`, `BookMetadata`, `Chapter`, `ConversionPlan`, `Settings`.
- Produces: `ensure_within(path: Path, root: Path) -> Path`.
- Produces: `build_plan(...) -> ConversionPlan`, raising `PlanError`.
- Produces: `render_command(argv: Sequence[str]) -> str` for preview only.
- Produces: `Converter.run(plan: ConversionPlan, log_path: Path) -> ValidationResult`.
- Produces: `finalize_success(plan: ConversionPlan, roots: Roots) -> Path`.
- Produces: `move_to_review(source: Path, roots: Roots) -> Path`.
- Produces: `find_stale(settings: Settings) -> tuple[Path, ...]` and `cleanup_stale(paths, settings) -> None`.
- [ ] **Step 1: Write failing planner security tests**
```python
# tests/test_planner.py
from pathlib import Path
import pytest
from app.planner import PlanError, build_plan, ensure_within
def test_containment_rejects_parent_escape(tmp_path: Path) -> None:
with pytest.raises(PlanError, match="outside configured root"):
ensure_within(tmp_path / "ready" / ".." / "escape.m4b", tmp_path / "ready")
def test_plan_never_overwrites_existing_output(plan_inputs) -> None:
plan_inputs.output.parent.mkdir()
plan_inputs.output.touch()
with pytest.raises(PlanError, match="already exists"):
build_plan(**plan_inputs.kwargs)
def test_plan_uses_argument_vector_without_shell(plan_inputs) -> None:
plan = build_plan(**plan_inputs.kwargs)
assert plan.argv[0:2] == ("m4b-tool", "merge")
assert "shell=True" not in plan.argv
assert plan.argv[-1] == str(plan.book.source)
```
- [ ] **Step 2: Run planner tests and verify RED**
Run: `python -m pytest tests/test_planner.py -v`
Expected: import fails because `app.planner` does not exist.
- [ ] **Step 3: Implement contained paths and deterministic plan construction**
Resolve roots and candidate parents independently before appending final filename so nonexistent targets remain checkable. Reject symlink escapes, existing final or partial outputs, control characters, and empty filenames. Build `m4b-tool merge --jobs=<n> --output-file=<partial> --name=<title> --artist=<author>` plus nonempty supported metadata arguments and source. Keep manual chapter application as explicit post-merge argv returned by helper `build_ffmetadata_argv`.
- [ ] **Step 4: Run planner tests and verify GREEN**
Run: `python -m pytest tests/test_planner.py -v`
Expected: all planner tests pass.
- [ ] **Step 5: Write failing converter and workflow tests**
```python
# tests/test_converter.py
def test_failed_converter_leaves_source_and_partial(conversion_fixture) -> None:
result = conversion_fixture.converter(exit_code=7).run(conversion_fixture.plan, conversion_fixture.log)
assert result.ok is False
assert conversion_fixture.plan.book.source.exists()
assert conversion_fixture.plan.output.exists() is False
def test_validation_rejects_wrong_chapter_count(conversion_fixture) -> None:
result = conversion_fixture.converter(exit_code=0, chapters=0).run(conversion_fixture.plan, conversion_fixture.log)
assert result.ok is False
assert "chapter count" in result.errors[0]
```
```python
# tests/test_workflow.py
def test_finalize_renames_partial_then_moves_source_to_done(workflow_fixture) -> None:
destination = finalize_success(workflow_fixture.plan, workflow_fixture.roots)
assert workflow_fixture.plan.output.exists()
assert destination.parent == workflow_fixture.roots.done
assert workflow_fixture.plan.book.source.exists() is False
def test_review_move_requires_confirmed_call_and_refuses_collision(workflow_fixture) -> None:
collision = workflow_fixture.roots.review / workflow_fixture.plan.book.source.name
collision.mkdir(parents=True)
with pytest.raises(WorkflowError, match="already exists"):
move_to_review(workflow_fixture.plan.book.source, workflow_fixture.roots)
```
- [ ] **Step 6: Run converter/workflow tests and verify RED**
Run: `python -m pytest tests/test_converter.py tests/test_workflow.py -v`
Expected: imports fail because converter and workflow modules do not exist.
- [ ] **Step 7: Implement process lifecycle, validation, and moves**
Use `subprocess.Popen(argv, stdout=PIPE, stderr=STDOUT, text=True, start_new_session=True)` and stream each line to callback plus log. On `KeyboardInterrupt`, send `SIGTERM` to process group, wait 10 seconds, then `SIGKILL`. Validation runner calls `ffprobe` JSON for duration and chapters; compare duration within configured tolerance and compare normalized chapter titles/count. Return immutable `ValidationResult(ok, errors)`.
Finalize with `os.replace(partial, output)` only after validation. Same-device source move uses `os.replace`. Cross-device path copies to `.moving-<name>`, compares recursive relative paths, sizes, and SHA-256 values, renames copy, then deletes original tree/file. Stale cleanup accepts only paths revalidated beneath ready or work root.
- [ ] **Step 8: Run task suite and commit**
Run: `python -m pytest tests/test_planner.py tests/test_converter.py tests/test_workflow.py -v`
Expected: all tests pass.
```bash
git add app/planner.py app/converter.py app/workflow.py tests/test_planner.py tests/test_converter.py tests/test_workflow.py
git commit -m "feat: execute and finalize safe audiobook conversions"
```
### Task 5: Interactive Terminal Workflow and Composition Root
**Files:**
- Create: `app/tui.py`
- Create: `app/main.py`
- Create: `tests/test_tui.py`
- Create: `tests/test_main.py`
**Interfaces:**
- Consumes all service interfaces from Tasks 14.
- Produces: `AudiobookApp.run() -> int`.
- Produces: `confirm_exact(prompt: str, token: str, session: PromptSession[str]) -> bool`.
- Produces: `main() -> int` and noninteractive `audiobook-tui doctor` diagnostics.
- [ ] **Step 1: Write failing confirmation and navigation tests**
```python
# tests/test_tui.py
from app.tui import confirm_exact
class Session:
def __init__(self, answer: str): self.answer = answer
def prompt(self, prompt: str) -> str: return self.answer
def test_convert_confirmation_is_exact_and_case_sensitive() -> None:
assert confirm_exact("convert", "CONVERT", Session("CONVERT")) is True
assert confirm_exact("convert", "CONVERT", Session("convert")) is False
def test_review_confirmation_rejects_surrounding_text() -> None:
assert confirm_exact("review", "REVIEW", Session(" REVIEW ")) is False
```
Write a scripted `UIAdapter` fake that returns queue selection, metadata choice, chapter mode, and confirmation. Assert cancellation produces zero converter/workflow calls; approved conversion calls scanner → chapter builder → planner → converter → finalizer in order.
- [ ] **Step 2: Run TUI tests and verify RED**
Run: `python -m pytest tests/test_tui.py tests/test_main.py -v`
Expected: imports fail because TUI and main modules do not exist.
- [ ] **Step 3: Implement testable terminal flow**
Keep Rich rendering and prompt-toolkit input behind `UIAdapter`. Render queue counts, 80-column-safe track table, metadata source/editor, chapter choice/preview, escaped command, paths, warnings, converter stream, and completion/failure summary. Exact-token prompt must compare raw input without trimming or case folding. Quit/back paths return without mutation.
`main()` parses `run` and `doctor` through `argparse`. `doctor` checks config, roots, writable ready/done/review parents, TTY state, and executable versions without changing audiobook state. `run` rejects absent stdin/stdout TTY with actionable SSH/Compose message.
- [ ] **Step 4: Run TUI tests and verify GREEN**
Run: `python -m pytest tests/test_tui.py tests/test_main.py -v`
Expected: all TUI and entry-point tests pass.
- [ ] **Step 5: Run full Python suite and commit**
Run: `python -m pytest -v`
Expected: all tests pass with no warnings.
```bash
git add app/tui.py app/main.py tests/test_tui.py tests/test_main.py
git commit -m "feat: add SSH-friendly audiobook terminal workflow"
```
### Task 6: Container Packaging, Smoke Conversion, and Operator Documentation
**Files:**
- Create: `Dockerfile`
- Create: `compose.yaml`
- Create: `.env.example`
- Create: `config.toml`
- Create: `.dockerignore`
- Create: `scripts/entrypoint.sh`
- Create: `scripts/container-smoke.sh`
- Create: `tests/test_packaging.py`
- Create: `README.md`
- Create: `LICENSE`
**Interfaces:**
- Compose service name: `audiobook-tui`.
- Launch: `docker compose run --rm audiobook-tui`.
- Diagnostics: `docker compose run --rm audiobook-tui doctor`.
- Required environment: `PUID`, `PGID`, `AUDIOBOOK_PENDING_HOST`, `AUDIOBOOK_READY_HOST`, `AUDIOBOOK_DONE_HOST`, `AUDIOBOOK_REVIEW_HOST`.
- [ ] **Step 1: Write failing static packaging tests**
```python
# tests/test_packaging.py
from pathlib import Path
import yaml
def test_compose_has_tty_stdin_and_four_mounts() -> None:
compose = yaml.safe_load(Path("compose.yaml").read_text())
service = compose["services"]["audiobook-tui"]
assert service["stdin_open"] is True
assert service["tty"] is True
assert {item["target"] for item in service["volumes"]} >= {"/data/pending", "/data/ready", "/data/done", "/data/review"}
def test_config_defaults_match_container_roots() -> None:
text = Path("config.toml").read_text()
for root in ("/data/pending", "/data/ready", "/data/done", "/data/review"):
assert root in text
```
Add `PyYAML==6.0.2` to test dependencies before running this test.
- [ ] **Step 2: Run packaging tests and verify RED**
Run: `python -m pytest tests/test_packaging.py -v`
Expected: fails because `compose.yaml` and `config.toml` do not exist.
- [ ] **Step 3: Create Docker and Compose packaging**
```dockerfile
ARG M4B_TOOL_IMAGE=sandreas/m4b-tool:latest
FROM ${M4B_TOOL_IMAGE}
RUN apk add --no-cache python3~=3.12 py3-pip shadow su-exec
WORKDIR /app
COPY pyproject.toml /app/
COPY app /app/app
RUN python3 -m pip install --no-cache-dir --break-system-packages .
COPY config.toml /app/config.toml
COPY scripts/entrypoint.sh /usr/local/bin/audiobook-entrypoint
ENTRYPOINT ["/usr/local/bin/audiobook-entrypoint"]
CMD ["run"]
```
Implement entrypoint to validate numeric `PUID`/`PGID`, create matching runtime user/group, verify mounted roots, and `exec su-exec "$PUID:$PGID" audiobook-tui "$@"`. Pin `M4B_TOOL_IMAGE` in `.env.example` to tested dated tag or digest discovered during implementation; never ship `latest` after smoke verification.
Compose uses long mount syntax, `stdin_open: true`, `tty: true`, `init: true`, `network_mode: bridge`, no Docker socket, no privileged mode, `cap_drop: [ALL]`, and only capabilities proven necessary by entrypoint user mapping. Prefer image build-time fixed UID/GID if dropping all capabilities prevents safe runtime identity mapping; document chosen behavior.
- [ ] **Step 4: Run static tests and Compose validation**
Run: `python -m pytest tests/test_packaging.py -v`
Expected: all packaging tests pass.
Run: `docker compose --env-file .env.example config --quiet`
Expected: exit 0.
- [ ] **Step 5: Create container smoke test**
`scripts/container-smoke.sh` must run `m4b-tool --version`, `ffmpeg -version`, `ffprobe -version`, generate two one-second sine-wave MP3s with distinct title tags, launch application conversion services noninteractively through a small Python integration harness, and assert final M4B has positive duration plus two expected chapters. Use `mktemp -d`, trap cleanup, and no host audiobook paths.
- [ ] **Step 6: Build and run smoke test**
Run: `docker compose --env-file .env.example build --pull`
Expected: exit 0.
Run: `docker compose --env-file .env.example run --rm audiobook-tui doctor`
Expected: dependencies and configured test mounts report healthy.
Run: `docker compose --env-file .env.example run --rm --entrypoint /app/scripts/container-smoke.sh audiobook-tui`
Expected: prints `container smoke test passed` and exits 0.
- [ ] **Step 7: Write README and license**
README sections: purpose, safety model, Enterprise-D prerequisites, copy/install commands, creation of four sibling directories, `.env` setup using `id -u`/`id -g`, build, SSH launch, doctor command, queue workflow, chapter modes and exact `chapters.txt` format, Audnexus policy/disable switch, output collisions, Ctrl-C/stale partial recovery, logs, backup expectations, permissions troubleshooting, pinned image upgrades, and manual acceptance checklist. Use MIT license unless user specifies another license before implementation.
- [ ] **Step 8: Run complete verification**
Run: `python -m pytest -v`
Expected: all Python tests pass with zero failures.
Run: `docker compose --env-file .env.example config --quiet`
Expected: exit 0.
Run: `docker compose --env-file .env.example build`
Expected: exit 0.
Run: `docker compose --env-file .env.example run --rm --entrypoint /app/scripts/container-smoke.sh audiobook-tui`
Expected: `container smoke test passed` and exit 0.
- [ ] **Step 9: Commit**
```bash
git add Dockerfile compose.yaml .env.example config.toml .dockerignore scripts tests/test_packaging.py README.md LICENSE pyproject.toml
git commit -m "build: package audiobook TUI for Docker Compose"
```
### Task 7: Requirements Audit and Release Bundle
**Files:**
- Modify: `README.md`
- Create: `CHANGELOG.md`
- Create: `dist/.gitkeep`
**Interfaces:**
- Produces copyable archive `dist/audiobook-tui-0.1.0.tar.gz`.
- [ ] **Step 1: Audit every spec success criterion**
Create temporary checklist from spec Success Criteria, Chapter Modes, Validation and Recovery, Configuration, Testing Strategy, and Deliverables. For each line, record implementation file and verifying test/command. Fix any uncovered requirement with a failing test first.
- [ ] **Step 2: Run fresh full verification**
Run: `python -m pytest -v`
Expected: zero failures and zero warnings.
Run: `docker compose --env-file .env.example config --quiet`
Expected: exit 0.
Run: `docker compose --env-file .env.example build`
Expected: exit 0.
Run: `docker compose --env-file .env.example run --rm --entrypoint /app/scripts/container-smoke.sh audiobook-tui`
Expected: `container smoke test passed` and exit 0.
- [ ] **Step 3: Build portable archive**
Run from project parent so archive has one top-level `audiobook-tui/` directory. Exclude `.git`, `.pytest_cache`, `__pycache__`, `.env`, `state`, `work`, existing `dist`, and local audio. Include `.env.example`, spec, implementation plan, source, tests, config, Compose, Dockerfile, scripts, README, license, and changelog.
Run: `tar -tzf dist/audiobook-tui-0.1.0.tar.gz`
Expected: only intended project files under one `audiobook-tui/` prefix; no `.env`, credentials, logs, audio, caches, or Git data.
- [ ] **Step 4: Commit release metadata**
```bash
git add README.md CHANGELOG.md dist/.gitkeep
git commit -m "docs: prepare audiobook TUI 0.1.0 release"
```
- [ ] **Step 5: Present handoff**
Report exact test count, Compose validation result, image/smoke result, archive SHA-256, archive path, and Enterprise-D launch commands. State any command not run and reason without implying completion.