build: package audiobook TUI for Docker Compose
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.github
|
||||
.pytest_cache
|
||||
.venv
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*.m4b
|
||||
*.mp3
|
||||
.env
|
||||
state
|
||||
work
|
||||
dist
|
||||
.superpowers
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Executable
+95
@@ -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'
|
||||
Executable
+46
@@ -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 "$@"
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user