build: package audiobook TUI for Docker Compose

This commit is contained in:
drockhollaback
2026-08-26 21:47:40 -04:00
parent f9e0d93c6d
commit 78b3f599f8
11 changed files with 599 additions and 1 deletions
+13
View File
@@ -0,0 +1,13 @@
.git
.github
.pytest_cache
.venv
__pycache__
*.py[cod]
*.m4b
*.mp3
.env
state
work
dist
.superpowers
+14
View File
@@ -0,0 +1,14 @@
# Stable upstream image published for linux/amd64 and linux/arm64.
M4B_TOOL_IMAGE=sandreas/m4b-tool:0.5.2
# Set these to the Enterprise-D account that owns the audiobook directories.
PUID=1000
PGID=1000
AUDIOBOOK_PENDING_HOST=/mnt/riker/alexandria/process/audiobook
AUDIOBOOK_READY_HOST=/mnt/riker/alexandria/process/audiobook-ready
AUDIOBOOK_DONE_HOST=/mnt/riker/alexandria/process/audiobook-done
AUDIOBOOK_REVIEW_HOST=/mnt/riker/alexandria/process/audiobook-review
# Project-local persistent logs. Create this directory before launching Compose.
AUDIOBOOK_STATE_HOST=./state
+20
View File
@@ -0,0 +1,20 @@
ARG M4B_TOOL_IMAGE=sandreas/m4b-tool:0.5.2
FROM ${M4B_TOOL_IMAGE}
RUN apk add --no-cache python3 py3-pip
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
COPY scripts/container-smoke.sh /app/scripts/container-smoke.sh
RUN mkdir -p /app/state \
&& chmod 0777 /app/state \
&& chmod 0555 /usr/local/bin/audiobook-entrypoint /app/scripts/container-smoke.sh
# The upstream image starts m4b-tool directly. This image starts the application.
ENTRYPOINT ["/usr/local/bin/audiobook-entrypoint"]
CMD ["run"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Derek Holodak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+268
View File
@@ -0,0 +1,268 @@
# Audiobook TUI
Audiobook TUI is a keyboard-driven queue for converting MP3 audiobooks into
validated M4B files on a headless Linux server. It is designed to be copied to
Enterprise-D, launched through SSH, and run one book at a time with explicit
operator approval.
The application discovers a source in `pending`, previews its tracks and
metadata, creates chapters, runs `m4b-tool`, validates the resulting M4B with
`ffprobe`, publishes it to `ready`, and only then moves the source to `done`.
An operator can instead move a source to `review` after a separate typed
confirmation.
## Safety model
- Conversion requires typing `CONVERT` exactly. Review moves require `REVIEW`.
- Existing final, partial, work, done, and review destinations are never
overwritten.
- Inputs remain unchanged during conversion. A source moves only after the
exact validated output has been published.
- Paths and symlinks are checked against their configured roots.
- Interrupted and failed conversions leave source material in `pending` and
preserve partial/work artifacts for explicit recovery.
- The container runs as the numeric `PUID:PGID` supplied by Compose, drops all
Linux capabilities, enables `no-new-privileges`, is not privileged, and does
not mount the Docker socket.
This safety model is not a backup. Keep independent backups of source audio and
the ready library.
## Enterprise-D prerequisites
Enterprise-D must provide:
- Linux with Docker Engine and the Docker Compose plugin (`docker compose`)
- SSH access with terminal allocation (`ssh -t`)
- a user that can read and write all four audiobook directories
- enough free space for the source, temporary merge, partial M4B, and final M4B
- outbound HTTPS only if optional Audnexus lookup is enabled
- either `linux/amd64` or `linux/arm64` for the default
`sandreas/m4b-tool:0.5.2` image
The Docker CLI and daemon were not available on the development desktop used
to create this package. The Python tests were run locally; every Docker command
below must be run and verified on Enterprise-D before the service is considered
accepted there. In particular, do not treat the presence of `compose.yaml` as
proof that Enterprise-D can pull, build, or run the image.
## Copy and install
From the machine containing this project, copy it to Enterprise-D without local
caches, state, or audio:
```sh
rsync -av \
--exclude .git --exclude .pytest_cache --exclude __pycache__ \
--exclude .env --exclude state --exclude '*.mp3' --exclude '*.m4b' \
./audiobook-tui/ Enterprise-D:/opt/audiobook-tui/
```
Then connect and enter the project:
```sh
ssh Enterprise-D
cd /opt/audiobook-tui
```
Create the four sibling workflow directories and persistent log directory. Run
this as the account that will operate the application:
```sh
install -d -m 0775 \
/mnt/riker/alexandria/process/audiobook \
/mnt/riker/alexandria/process/audiobook-ready \
/mnt/riker/alexandria/process/audiobook-done \
/mnt/riker/alexandria/process/audiobook-review \
./state
```
Create the deployment environment file:
```sh
cp .env.example .env
id -u
id -g
```
Edit `.env`: set `PUID` to the `id -u` result and `PGID` to the `id -g`
result. Confirm all four `AUDIOBOOK_*_HOST` values and
`AUDIOBOOK_STATE_HOST`. All are required host-side deployment settings.
Do not commit `.env`.
## Build and verify on Enterprise-D
Run each command on Enterprise-D and require a zero exit status:
```sh
docker compose --env-file .env config --quiet
docker compose --env-file .env build --pull
docker compose --env-file .env run --rm audiobook-tui doctor
docker compose --env-file .env run --rm \
--entrypoint /app/scripts/container-smoke.sh audiobook-tui
```
The last command must finish with:
```text
container smoke test passed
```
The smoke test uses only a temporary directory inside the container. It prints
the installed `m4b-tool`, `ffmpeg`, and `ffprobe` versions, creates two synthetic
one-second MP3 tracks, runs the application's real conversion services, and
checks the final duration and two chapter titles. It does not touch host
audiobook paths.
## Launch over SSH
From an interactive Enterprise-D shell:
```sh
cd /opt/audiobook-tui
docker compose --env-file .env run --rm audiobook-tui
```
For a one-command remote launch, allocate a terminal explicitly:
```sh
ssh -t Enterprise-D \
'cd /opt/audiobook-tui && docker compose --env-file .env run --rm audiobook-tui'
```
Diagnostics can be repeated at any time:
```sh
docker compose --env-file .env run --rm audiobook-tui doctor
```
## Queue workflow
1. Put one loose MP3, or one directory containing MP3 tracks, directly beneath
the pending host directory.
2. Launch the TUI and select the source.
3. Choose Convert or Review. Review requires typing `REVIEW` and moves the
source without converting it.
4. For conversion, inspect the naturally sorted tracks, choose local/manual or
optional Audnexus metadata, select a chapter mode, and inspect every path and
chapter in the final preview.
5. Type `CONVERT` exactly. On success, the validated `.m4b` appears in `ready`
and the original source appears in `done`.
The directories are the queue database: `pending` means waiting,
`ready` contains validated output, `done` contains completed sources, and
`review` contains sources intentionally set aside.
## Chapter modes
- **Embedded:** each sorted MP3 boundary becomes a chapter, using that track's
embedded title. Every track must have a title.
- **Filenames:** each sorted MP3 boundary becomes a chapter; a conservative
leading track-number prefix is removed from the filename.
- **Manual:** a UTF-8 `chapters.txt` provides explicit timestamps and titles.
The exact manual format is one nonblank chapter per line:
```text
00:00:00 Opening Credits
00:01:42.500 Chapter 1
01:14:09 Chapter 2
```
Timestamps are `HH:MM:SS` or `HH:MM:SS.mmm`. The first must be
`00:00:00`; later values must be strictly increasing and precede total audio
duration. A title is required after one or more spaces. Invalid UTF-8, duplicate
timestamps, malformed lines, and out-of-range timestamps block conversion.
## Audnexus
Audnexus is optional, unauthenticated enrichment. Network failure, malformed
responses, rate limits, and missing results fall back to local/manual metadata.
No API key is accepted. If the public service becomes paid, credentialed, or
otherwise unsuitable, disable it in `config.toml`:
```toml
[audnexus]
enabled = false
```
The rest of the workflow remains available without network access.
## Collisions and recovery
If the selected output name already exists in `ready`, choose another name or
cancel. The application will not replace it. The same rule applies when a
source name already exists in `done` or `review`.
Pressing Ctrl-C during conversion terminates the child conversion process. The
source stays in `pending`; a `<name>.partial.m4b` and/or `<name>.work` directory
may remain. On the next launch, the TUI lists exact stale paths. Preserve them
for investigation or select the displayed artifacts and confirm cleanup. The
application does not resume ambiguous work or delete outside the configured
ready and work roots.
Persistent conversion logs are written beneath the directory mounted at
`/app/state`, normally `./state/logs` on Enterprise-D. Back up logs if they are
part of your audit trail. Temporary merge data lives beneath
`/tmp/audiobook-tui` in the ephemeral container and is not a backup.
## Permissions troubleshooting
If the entrypoint reports an identity mismatch, compare `.env` with the
Enterprise-D operator account:
```sh
id -u
id -g
```
If `doctor` reports an unreadable or unwritable root, inspect every host path
and the state directory with `ls -ld`. Correct ownership or group permissions
on the host; do not add privileged mode, capabilities, or a Docker socket.
Files created in `ready`, `done`, `review`, and `state` should be owned by the
configured numeric identity. ACLs, NFS root-squash, or mismatched NAS IDs may
also prevent writes even when Unix mode bits look plausible.
## Pinned image upgrades
The normal build path is pinned to `sandreas/m4b-tool:0.5.2`; it never defaults
to `latest`. To upgrade intentionally:
1. Read the upstream release notes and confirm the candidate supports the
Enterprise-D architecture.
2. Change `M4B_TOOL_IMAGE` in `.env` (and `.env.example` when adopting the pin
for everyone) to a stable version tag or immutable digest.
3. Run Compose configuration, `build --pull`, `doctor`, and the synthetic smoke
command from the Enterprise-D verification section.
4. Run the manual acceptance checklist with disposable or backed-up test data.
5. Keep the old pin recorded so rollback is a one-line `.env` change and rebuild.
Never promote a new pin solely because the image builds.
## Manual Enterprise-D acceptance checklist
- [ ] `docker compose --env-file .env config --quiet` exits zero.
- [ ] The image pulls/builds for Enterprise-D's CPU architecture.
- [ ] `doctor` reports all mounts, the TTY, and three media tools healthy.
- [ ] The container smoke test prints `container smoke test passed`.
- [ ] Launch through `ssh -t` is interactive and remains usable at 80 columns.
- [ ] Pending content is visible and all destination paths are writable.
- [ ] A disposable conversion produces a playable M4B with expected chapters.
- [ ] Output, moved source, and log ownership match `PUID:PGID`.
- [ ] An existing output collision is refused without changing either file.
- [ ] Ctrl-C leaves the source pending and stale cleanup lists only expected paths.
- [ ] Successful conversion yields the expected pending → ready + done transition.
- [ ] `REVIEW` yields the expected pending → review transition.
- [ ] Independent source and ready-library backups have been confirmed.
## Development verification status
Local Python tests are the only verified checks from the authoring desktop.
Docker Compose configuration, image build, container diagnostics, and container
smoke conversion are **not verified there** because neither the Docker CLI nor
daemon is installed. Run the Enterprise-D commands above and retain their output
before declaring deployment acceptance.
## License
MIT. See `LICENSE`.
+50
View File
@@ -0,0 +1,50 @@
services:
audiobook-tui:
build:
context: .
args:
M4B_TOOL_IMAGE: ${M4B_TOOL_IMAGE:-sandreas/m4b-tool:0.5.2}
user: "${PUID}:${PGID}"
environment:
PUID: ${PUID}
PGID: ${PGID}
AUDIOBOOK_PENDING: /data/pending
AUDIOBOOK_READY: /data/ready
AUDIOBOOK_DONE: /data/done
AUDIOBOOK_REVIEW: /data/review
AUDIOBOOK_STATE_DIR: /app/state
AUDIOBOOK_WORK_DIR: /tmp/audiobook-tui
stdin_open: true
tty: true
init: true
network_mode: bridge
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
volumes:
- type: bind
source: ${AUDIOBOOK_PENDING_HOST}
target: /data/pending
bind:
create_host_path: false
- type: bind
source: ${AUDIOBOOK_READY_HOST}
target: /data/ready
bind:
create_host_path: false
- type: bind
source: ${AUDIOBOOK_DONE_HOST}
target: /data/done
bind:
create_host_path: false
- type: bind
source: ${AUDIOBOOK_REVIEW_HOST}
target: /data/review
bind:
create_host_path: false
- type: bind
source: ${AUDIOBOOK_STATE_HOST:-./state}
target: /app/state
bind:
create_host_path: false
+19
View File
@@ -0,0 +1,19 @@
[paths]
pending = "/data/pending"
ready = "/data/ready"
done = "/data/done"
review = "/data/review"
state_dir = "/app/state"
work_dir = "/tmp/audiobook-tui"
[conversion]
jobs = 2
[audnexus]
enabled = true
base_url = "https://api.audnex.us"
connect_timeout = 3.0
read_timeout = 8.0
[validation]
duration_tolerance_ms = 3000
+1 -1
View File
@@ -13,7 +13,7 @@ dependencies = [
]
[project.optional-dependencies]
test = ["pytest==8.4.2", "pytest-asyncio==1.1.0"]
test = ["PyYAML==6.0.2", "pytest==8.4.2", "pytest-asyncio==1.1.0"]
[project.scripts]
audiobook-tui = "app.main:main"
+95
View File
@@ -0,0 +1,95 @@
#!/bin/sh
set -eu
m4b-tool --version
m4b-tool merge --help >/dev/null
ffmpeg -version
ffprobe -version
smoke_root=$(mktemp -d "${TMPDIR:-/tmp}/audiobook-tui-smoke.XXXXXX")
trap 'rm -rf "$smoke_root"' EXIT HUP INT TERM
export AUDIOBOOK_SMOKE_ROOT="$smoke_root"
mkdir -p \
"$smoke_root/pending/Synthetic Book" \
"$smoke_root/ready" \
"$smoke_root/done" \
"$smoke_root/review" \
"$smoke_root/state" \
"$smoke_root/work"
ffmpeg -nostdin -hide_banner -loglevel error \
-f lavfi -i 'sine=frequency=440:duration=1' \
-metadata title='Opening Tone' -q:a 5 \
"$smoke_root/pending/Synthetic Book/01-opening.mp3"
ffmpeg -nostdin -hide_banner -loglevel error \
-f lavfi -i 'sine=frequency=880:duration=1' \
-metadata title='Closing Tone' -q:a 5 \
"$smoke_root/pending/Synthetic Book/02-closing.mp3"
python3 - <<'PY'
import os
from pathlib import Path
import subprocess
from app.chapters import chapters_from_embedded
from app.converter import Converter
from app.metadata import infer_metadata
from app.models import Roots, Settings
from app.planner import build_plan
from app.scanner import scan_book
from app.workflow import finalize_success
root = Path(os.environ["AUDIOBOOK_SMOKE_ROOT"])
roots = Roots(
pending=root / "pending",
ready=root / "ready",
done=root / "done",
review=root / "review",
)
settings = Settings(
roots=roots,
state_dir=root / "state",
work_dir=root / "work",
jobs=1,
audnexus_enabled=False,
)
def run_probe(argv: tuple[str, ...]) -> subprocess.CompletedProcess[str]:
return subprocess.run(argv, capture_output=True, text=True, check=False)
source = roots.pending / "Synthetic Book"
book = scan_book(source, run_probe)
chapters = chapters_from_embedded(book)
plan = build_plan(book, infer_metadata(book), chapters, "Synthetic Book.m4b", settings)
validation = Converter(settings).run(plan, settings.state_dir / "smoke.log")
if not validation.ok:
raise SystemExit("conversion validation failed: " + "; ".join(validation.errors))
finalize_success(plan, roots, validation)
if source.exists():
raise SystemExit("source was not moved out of pending")
if not (roots.done / source.name).is_dir():
raise SystemExit("source was not moved to done")
PY
ffprobe -v error \
-show_entries 'format=duration:chapter_tags=title' \
-of json "$smoke_root/ready/Synthetic Book.m4b" \
>"$smoke_root/probe.json"
python3 - <<'PY'
import json
import os
from pathlib import Path
root = Path(os.environ["AUDIOBOOK_SMOKE_ROOT"])
payload = json.loads((root / "probe.json").read_text(encoding="utf-8"))
duration = float(payload["format"]["duration"])
titles = [chapter.get("tags", {}).get("title") for chapter in payload["chapters"]]
if duration <= 0:
raise SystemExit("smoke output duration is not positive")
if titles != ["Opening Tone", "Closing Tone"]:
raise SystemExit(f"smoke chapters did not match: {titles!r}")
PY
printf '%s\n' 'container smoke test passed'
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
set -eu
fail() {
printf 'audiobook-entrypoint: %s\n' "$*" >&2
exit 1
}
require_numeric() {
variable_name=$1
eval "variable_value=\${$variable_name:-}"
case "$variable_value" in
''|*[!0-9]*) fail "$variable_name must be a non-negative numeric ID" ;;
esac
}
require_directory() {
label=$1
path=$2
[ -d "$path" ] || fail "$label directory is not mounted: $path"
}
require_numeric PUID
require_numeric PGID
[ "$(id -u)" = "$PUID" ] || fail "container UID $(id -u) does not match PUID $PUID"
[ "$(id -g)" = "$PGID" ] || fail "container GID $(id -g) does not match PGID $PGID"
require_directory pending "${AUDIOBOOK_PENDING:-/data/pending}"
require_directory ready "${AUDIOBOOK_READY:-/data/ready}"
require_directory done "${AUDIOBOOK_DONE:-/data/done}"
require_directory review "${AUDIOBOOK_REVIEW:-/data/review}"
require_directory state "${AUDIOBOOK_STATE_DIR:-/app/state}"
[ -r "${AUDIOBOOK_PENDING:-/data/pending}" ] || fail "pending directory is not readable"
for destination in \
"${AUDIOBOOK_PENDING:-/data/pending}" \
"${AUDIOBOOK_READY:-/data/ready}" \
"${AUDIOBOOK_DONE:-/data/done}" \
"${AUDIOBOOK_REVIEW:-/data/review}" \
"${AUDIOBOOK_STATE_DIR:-/app/state}"
do
[ -w "$destination" ] || fail "directory is not writable by $PUID:$PGID: $destination"
done
exec audiobook-tui "$@"
+52
View File
@@ -0,0 +1,52 @@
from pathlib import Path
import yaml
ROOTS = {"/data/pending", "/data/ready", "/data/done", "/data/review"}
def _service() -> dict[str, object]:
compose = yaml.safe_load(Path("compose.yaml").read_text(encoding="utf-8"))
return compose["services"]["audiobook-tui"]
def test_compose_has_tty_stdin_and_four_mounts() -> None:
service = _service()
assert service["stdin_open"] is True
assert service["tty"] is True
assert {item["target"] for item in service["volumes"]} >= ROOTS
def test_compose_runs_as_requested_user_without_privilege_escalation() -> None:
service = _service()
assert service["user"] == "${PUID}:${PGID}"
assert service["cap_drop"] == ["ALL"]
assert service.get("privileged", False) is False
assert all("docker.sock" not in str(item) for item in service["volumes"])
def test_config_defaults_match_container_roots() -> None:
text = Path("config.toml").read_text(encoding="utf-8")
for root in ROOTS:
assert root in text
def test_image_pin_and_application_entrypoint_are_explicit() -> None:
dockerfile = Path("Dockerfile").read_text(encoding="utf-8")
environment = Path(".env.example").read_text(encoding="utf-8")
assert "sandreas/m4b-tool:0.5.2" in dockerfile
assert "M4B_TOOL_IMAGE=sandreas/m4b-tool:0.5.2" in environment
assert "latest" not in dockerfile
assert 'ENTRYPOINT ["/usr/local/bin/audiobook-entrypoint"]' in dockerfile
def test_container_smoke_script_is_copied_to_its_documented_path() -> None:
dockerfile = Path("Dockerfile").read_text(encoding="utf-8")
assert "COPY scripts/container-smoke.sh /app/scripts/container-smoke.sh" in dockerfile
assert Path("scripts/container-smoke.sh").is_file()