32 KiB
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-toolperforms merge/conversion; bundledffmpegmay apply manual chapter metadata afterward.- Audnexus remains optional, public, unauthenticated, timeout-limited, and failure-safe.
- Conversion requires exact
CONVERT; review move requires exactREVIEW. - 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,ffprobeexecution, 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, raisingConfigErrorwith all startup errors. -
Step 1: Create package metadata and failing configuration tests
[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"]
# 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
# 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
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,Rootsfromapp.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. -
ProbeRunnersignature:Callable[[tuple[str, ...]], subprocess.CompletedProcess[str]]. -
Step 1: Write failing discovery and ordering tests
# 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
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.
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, ...]andclose() -> None. -
Step 1: Write failing chapter tests
# 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
# 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")
# 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.
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, raisingPlanError. -
Produces:
render_command(argv: Sequence[str]) -> strfor 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, ...]andcleanup_stale(paths, settings) -> None. -
Step 1: Write failing planner security tests
# 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
# 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]
# 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.
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 1–4.
-
Produces:
AudiobookApp.run() -> int. -
Produces:
confirm_exact(prompt: str, token: str, session: PromptSession[str]) -> bool. -
Produces:
main() -> intand noninteractiveaudiobook-tui doctordiagnostics. -
Step 1: Write failing confirmation and navigation tests
# 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.
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
# 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
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
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
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.