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.
@@ -0,0 +1,253 @@
# Portable Audiobook TUI Design
## Purpose
Build a portable Docker Compose project for manually processing audiobook sources on a headless server over SSH. The project converts one pending book at a time to M4B, requires explicit approval before destructive or expensive actions, and uses filesystem locations as workflow state.
## Success Criteria
- Launches through Docker Compose in an interactive SSH terminal without a desktop GUI.
- Processes a directory containing multiple MP3 files or one standalone MP3 into one M4B.
- Shows naturally sorted source tracks before conversion.
- Supports chapter names from embedded titles, filenames, or a validated `chapters.txt` file.
- Uses `m4b-tool` as the merge and conversion engine.
- Offers optional Audnexus enrichment through public unauthenticated endpoints and continues normally when Audnexus is disabled or unavailable.
- Requires typed confirmation before every conversion and before moving a source to review.
- Moves successful output and source into ready and done states without overwriting existing files.
- Ships as a documented project that can be copied to Enterprise-D and launched with Docker Compose.
## Runtime Architecture
Use one Compose service built from a pinned `sandreas/m4b-tool` base image. Add Python, the terminal application, and its pinned Python dependencies to that image. One container avoids mounting the Docker socket, coordinating worker containers, or reconciling permissions and signals across services. Compose remains the deployment boundary and can gain more services later without changing the user workflow.
Run the application as an ephemeral interactive container:
```bash
docker compose run --rm audiobook-tui
```
Compose must allocate stdin and a TTY. It maps the invoking server user's numeric UID and GID into the container so completed files do not become root-owned. The project supports Linux `amd64`; additional architectures are supported only when the selected pinned base image publishes them.
The image pin is exposed as a Docker build argument. Documentation must recommend a dated `m4b-tool` tag or immutable digest and explain how to test and intentionally upgrade it. `latest` is not the default because upstream describes it as potentially unstable.
## Host and Container Paths
Default host paths:
```text
/mnt/riker/alexandria/process/audiobook/
/mnt/riker/alexandria/process/audiobook-ready/
/mnt/riker/alexandria/process/audiobook-done/
/mnt/riker/alexandria/process/audiobook-review/
```
Compose maps these paths to stable container roots:
```text
/data/pending
/data/ready
/data/done
/data/review
```
Project-local persistent state maps to `/app/state` and contains logs and stale-work records. Temporary conversion data uses a configurable work directory. All configured roots are resolved before use. Inputs and destinations must remain beneath their expected root. Symlinks resolving outside those roots are rejected.
## Filesystem State Model
- **Pending:** source directory or supported loose source file exists under pending root.
- **Ready:** validated final `.m4b` exists under ready root.
- **Done:** original source has moved beneath done root after successful output validation.
- **Review:** source has moved beneath review root following explicit user confirmation.
No database tracks queue state. Directory contents remain source of truth.
A conversion writes `<name>.partial.m4b` under ready root. Final output appears only through atomic rename after validation. Existing final output is never overwritten. Name collisions require user to edit output name or cancel.
Source moves to done only after final output exists and passes validation. Same-filesystem moves use atomic rename. Cross-filesystem moves use copy to a temporary destination, verify copied tree, rename destination into place, then remove source. Failure before verified destination creation leaves source intact. Failure during source removal is reported as a reconciliation error and never treated as clean success.
## Queue and Input Discovery
Pending book units are:
- Immediate child directories containing supported audio files.
- Supported loose audio files directly beneath pending root; each loose file is one book.
Initial required input type is MP3. Scanner structure may allow later formats, but interface and tests promise MP3 only. Hidden files, partial outputs, and unsupported files are ignored with visible warnings where relevant.
Track order uses a deterministic natural sort over relative path names, case-insensitive with stable case-sensitive fallback. Before conversion, preview shows ordinal, relative filename, duration, embedded title, track number, disc number, and scan warnings. User cannot approve conversion until probing succeeds for every selected track.
## Terminal Interaction
Use a keyboard-driven Python terminal interface suitable for common SSH terminals. Application remains usable at 80 columns and does not depend on mouse input, browser access, or terminal graphics protocols.
Flow:
1. Queue screen lists pending books and state counts.
2. User selects one book.
3. Track preview shows sorted inputs and probe results.
4. Metadata screen offers local inference, optional Audnexus results, and manual field editing.
5. Chapter screen selects embedded titles, filenames, or `chapters.txt`.
6. Final preview shows metadata, cover, chapters, source and destination paths, warnings, and shell-escaped conversion command.
7. User must type `CONVERT` exactly to begin conversion.
8. On success, application validates output, finalizes ready file, and moves source to done.
Moving a book to review is separate from conversion and requires typing `REVIEW` exactly. Escape/back navigation and quit do not mutate state.
## Metadata
Local metadata precedence is:
1. User edits made in current session.
2. Accepted Audnexus result.
3. Consistent embedded tags.
4. Folder or filename inference.
Core editable fields are title, author, narrator, series, series position, year, description, and output filename. Cover selection prefers an explicitly chosen local image, then conventional local filenames such as `cover.jpg`, then an accepted Audnexus image. Remote cover download failure does not block conversion.
Metadata and generated files are staged in temporary work space. Original MP3 files are never retagged.
## Audnexus Policy
Audnexus support is optional and disabled through configuration. It calls only documented public unauthenticated book, search, author, chapter, and image-related data paths rooted at `https://api.audnex.us`. It does not call authenticated metrics endpoints and exposes no API-key configuration.
Requests use configurable short connect and read timeouts, identify this client, and avoid automatic unbounded retries. Network failure, invalid response, rate limiting, missing result, or service outage returns user to local/manual metadata without blocking processing. Responses are untrusted external data: validate types and lengths before display, filesystem use, or command construction.
If public endpoints later require payment, subscription, or credentials, README instructs user to disable Audnexus; application continues with local metadata.
## Chapter Modes
### Embedded Titles
Sorted track boundaries become chapter boundaries. Each chapter title comes from its source file's embedded title tag. Missing titles produce a visible validation error and must be corrected manually or by choosing another mode.
### Filenames
Sorted track boundaries become chapter boundaries. Chapter names come from basenames with file extension removed and conservative leading track-number punctuation removed. Preview always shows resulting title; no original file is renamed.
### Manual `chapters.txt`
Book directory contains UTF-8 `chapters.txt`, one chapter per nonblank line:
```text
00:00:00 Opening Credits
00:01:42.500 Chapter 1
01:14:09 Chapter 2
```
Parser accepts `HH:MM:SS` and `HH:MM:SS.mmm`. Timestamps must be nonnegative and strictly increasing, first timestamp must equal zero, every title must be nonempty, and every timestamp must precede probed total duration. Duplicate timestamps, malformed lines, invalid UTF-8, and chapters past duration block conversion with line-specific errors.
Implementation must inspect pinned `m4b-tool --help` during container verification. If pinned version provides reliable direct chapter-file ingestion, use it. Otherwise `m4b-tool` performs merge/conversion and bundled `ffmpeg` applies staged ffmetadata chapters to the converted temporary M4B. This fallback does not replace `m4b-tool` as conversion engine.
## Conversion Command
Command builder produces an argument vector and never invokes a shell. It passes selected sorted inputs, output path, metadata, cover, and configured encoding/job settings to `m4b-tool`. Final preview renders shell-escaped form for operator inspection only.
Multiple MP3 inputs merge in displayed order. Single MP3 input passes through same conversion workflow and produces M4B. Generated chapter metadata never changes source files.
Configuration exposes safe operational values such as job count and optional additional allowlisted `m4b-tool` settings. It does not accept an arbitrary command string.
## Validation and Recovery
Successful converter exit alone is insufficient. Output validation checks:
- Process exit status is zero.
- Partial output exists and has nonzero size.
- Media probe reads an audio duration greater than zero.
- Duration is within a small documented tolerance of source total.
- Expected chapter count and chapter titles are present.
Converter stdout and stderr stream into TUI and persistent timestamped log. Ctrl-C sends termination to child process, waits a bounded interval, then escalates termination if required. Interrupted or failed conversion leaves source pending and records partial/work artifacts.
On startup, stale partial files and work records are detected. Application shows exact paths and offers explicit cleanup or preservation. It never resumes an ambiguous conversion automatically and never deletes artifacts outside configured ready/work roots.
## Configuration
Ship:
- `compose.yaml` with default Enterprise-D bind mounts and TTY settings.
- `.env.example` for host path, UID, GID, image pin, and other deployment substitutions.
- `config.toml` for container paths, Audnexus toggle/timeouts, jobs, validation tolerance, and logging.
- Dockerfile with pinned Python dependencies layered onto pinned `m4b-tool` image.
Environment variables may override deployment-specific path and identity settings. Application settings stay in TOML. Startup validates configuration and reports all errors before scanning files.
## Code Boundaries
- `app/config.py`: load and validate TOML/environment configuration.
- `app/models.py`: immutable book, track, metadata, chapter, and conversion-plan types.
- `app/scanner.py`: discover queue items, natural-sort tracks, and probe media.
- `app/chapters.py`: build and validate all chapter modes.
- `app/metadata.py`: infer and normalize local metadata.
- `app/audnexus.py`: bounded public API client and response validation.
- `app/planner.py`: resolve safe destinations and build conversion argument vectors.
- `app/converter.py`: execute process, stream logs, handle signals, and validate output.
- `app/workflow.py`: finalize output and move sources between filesystem states.
- `app/tui.py`: screens, navigation, previews, and typed confirmations.
- `app/main.py`: application entry point and dependency assembly.
Modules communicate through explicit data types. Scanner, chapter builder, planner, converter, and workflow remain usable without TUI for tests.
## Testing Strategy
Unit tests cover:
- Natural sorting and discovery filtering.
- Probe result normalization.
- Metadata precedence and safe output names.
- Every `chapters.txt` validation rule.
- Embedded-title and filename chapter generation.
- Root containment and symlink rejection.
- Collision behavior and argument-vector construction.
- Audnexus schema validation and graceful failure.
- State transition preconditions.
Integration tests use temporary roots and a fake converter executable to cover:
- Multi-MP3 successful conversion.
- Single-MP3 successful conversion.
- Each chapter mode.
- Converter failure and interruption.
- Invalid output and destination collision.
- Successful done move and confirmed review move.
- Audnexus timeout and invalid response fallback.
- Stale artifact detection and cleanup boundaries.
Small synthetic audio fixtures are generated during tests; repository contains no copyrighted audiobook samples.
Container verification covers:
- `docker compose config` succeeds.
- Image builds for target platform.
- Python application help starts without TUI failure.
- `m4b-tool`, `ffmpeg`, and `ffprobe` are present.
- Pinned `m4b-tool --help` supports planned arguments or triggers documented ffmetadata fallback.
- Synthetic end-to-end MP3-to-M4B smoke conversion succeeds and validates chapters.
README includes manual Enterprise-D acceptance checklist: SSH TTY behavior, mounted-path visibility, UID/GID ownership, real conversion, Ctrl-C recovery, collision refusal, and correct pending/ready/done/review transitions.
## Deliverables
```text
audiobook-tui/
├── app/
├── tests/
├── Dockerfile
├── compose.yaml
├── config.toml
├── .env.example
├── pyproject.toml
├── README.md
└── LICENSE
```
README covers copying project to Enterprise-D, creating sibling directories, setting UID/GID, selecting or upgrading image pin, building, launching over SSH, configuration, chapter file format, recovery, logs, backup expectations, and troubleshooting permissions.
## Non-Goals
- Desktop GUI or web UI.
- Automated unattended queue conversion.
- Paid metadata services or credentialed Audnexus access.
- Database-backed history.
- Editing original MP3 tags.
- Recursive library management beyond immediate pending book units.
- Downloading, ripping, or acquiring audiobook media.