Files
audiobook-merge/docs/superpowers/plans/2026-08-26-audiobook-tui.md
2026-08-26 19:43:49 -04:00

32 KiB
Raw Blame History

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

[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, 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

# 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, ...] and close() -> 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, 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

# 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 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

# 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.