Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d082a7f1d | ||
|
|
5c8ac208a0 | ||
|
|
dbc2c67bcb | ||
|
|
1eb8b2199a | ||
|
|
0e4bb327da | ||
|
|
114dd7a93e | ||
|
|
e2393bacad | ||
|
|
ff11c37ee0 | ||
|
|
0fe23c5233 | ||
|
|
716eefef5d | ||
|
|
8816a9d498 | ||
|
|
032229c1e2 | ||
|
|
09b0badaf5 | ||
|
|
0e7936f94e |
@@ -79,3 +79,23 @@ MARRETA_APP_KEY=changeme
|
|||||||
# account on first boot from these values.
|
# account on first boot from these values.
|
||||||
MARRETA_ADMIN_EMAIL=admin@franksdank.cloud
|
MARRETA_ADMIN_EMAIL=admin@franksdank.cloud
|
||||||
MARRETA_ADMIN_PASSWORD=changeme
|
MARRETA_ADMIN_PASSWORD=changeme
|
||||||
|
|
||||||
|
# open-notebook (podcast generator; internal-only, no vhost).
|
||||||
|
# deploy-remote.sh seeds /home/dank/Containers/open-notebook/env from these
|
||||||
|
# values (only if that file does not already exist) and refuses to start the
|
||||||
|
# units while any of them is blank: a blank OPEN_NOTEBOOK_PASSWORD means
|
||||||
|
# authentication OFF, and a blank encryption key means no provider credential
|
||||||
|
# can be saved. The seed writes SURREAL_PASS too — the spelling the `surreal`
|
||||||
|
# CLI reads — from SURREAL_PASSWORD, so the app and the database cannot be
|
||||||
|
# given different values.
|
||||||
|
# OPEN_NOTEBOOK_ENCRYPTION_KEY: encrypts the stored AI provider keys at rest.
|
||||||
|
# Generate: openssl rand -hex 32
|
||||||
|
# Changing it after credentials are saved makes them undecryptable.
|
||||||
|
OPEN_NOTEBOOK_ENCRYPTION_KEY=changeme
|
||||||
|
# UI/API password. Every API route — including the episode-audio download the
|
||||||
|
# app serves without a session — is open to the LAN while this is unset.
|
||||||
|
OPEN_NOTEBOOK_PASSWORD=changeme
|
||||||
|
# SurrealDB root password (the root user NAME comes from
|
||||||
|
# scripts/open-notebook.env, so this file holds only the secret).
|
||||||
|
# Generate: openssl rand -base64 32
|
||||||
|
SURREAL_PASSWORD=changeme
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Quadlet unit: open-notebook's SurrealDB (rootless, boot-persistent).
|
||||||
|
#
|
||||||
|
# Install path on dankclaw: ~/.config/containers/systemd/open-notebook-surrealdb.container
|
||||||
|
# Unit name: open-notebook-surrealdb.service (container name: open-notebook-surrealdb)
|
||||||
|
#
|
||||||
|
# WHAT IT IS. open-notebook's only datastore: notebooks, notes, sources,
|
||||||
|
# provider credentials and podcast episodes all live here. The app talks to it
|
||||||
|
# over the WebSocket RPC on the private bridge (SURREAL_URL in
|
||||||
|
# scripts/open-notebook.env), which is why the container NAME is part of the
|
||||||
|
# contract — renaming it breaks the app's database URL, not just this file.
|
||||||
|
#
|
||||||
|
# IMAGE. `surrealdb/surrealdb:v2` pinned by manifest-list digest, resolved from
|
||||||
|
# the registry on 2026-10-18 (that digest is SurrealDB 2.7.0, image built
|
||||||
|
# 2026-09-23; `v2` itself is a MOVING tag, so the digest — not the tag — is what
|
||||||
|
# makes this reproducible). Upstream open-notebook's own compose file pins the
|
||||||
|
# same `v2` tag, and its Dockerfile copies /surreal out of the same image for
|
||||||
|
# the retired single-container variant, so v2 is the version this app is built
|
||||||
|
# against. Pull=never + the documented pull step (docs: [[Open Notebook]]):
|
||||||
|
#
|
||||||
|
# podman pull docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723
|
||||||
|
#
|
||||||
|
# NO PUBLISHED PORT. 8000 is deliberately not published: the app reaches it by
|
||||||
|
# name on systemd-open-notebook, and a published 8000 would put the RPC with
|
||||||
|
# the root credentials on every interface (upstream publishes 127.0.0.1:8000
|
||||||
|
# "purely for local debugging" — a `podman exec` probe is enough here). The
|
||||||
|
# binary binds 0.0.0.0:8000 INSIDE its own namespace via the image's
|
||||||
|
# SURREAL_BIND env; without that image default it would bind loopback only and
|
||||||
|
# the app could never connect, so no --bind flag belongs here.
|
||||||
|
#
|
||||||
|
# CREDENTIALS, AND WHY THEY ARE NOT IN THE COMMAND LINE. The `surreal start`
|
||||||
|
# CLI reads its options from the environment (`--username` is [env:
|
||||||
|
# SURREAL_USER=], `--password` is [env: SURREAL_PASS=] — verified against the
|
||||||
|
# pinned image's own `start --help`), so the credentials arrive through the two
|
||||||
|
# EnvironmentFile lines and the argv stays clean. Passing `--pass` instead —
|
||||||
|
# what upstream's compose does — would put the database password in
|
||||||
|
# /proc/<pid>/cmdline, readable by any `ps` on the host. Note the two
|
||||||
|
# spellings: the DATABASE reads SURREAL_PASS, the APP reads SURREAL_PASSWORD,
|
||||||
|
# and scripts/open-notebook.env declares both because they must hold the same
|
||||||
|
# value; the deployer writes that pair when it seeds the host secret file.
|
||||||
|
#
|
||||||
|
# NO USER= OVERRIDE. The image runs as uid 65532 (Chainguard glibc-dynamic
|
||||||
|
# base). With a NAMED volume that works unmodified: podman creates the volume's
|
||||||
|
# backing directory owned by the container's user (measured with this digest —
|
||||||
|
# the host directory lands on the mapped subuid and the store starts clean),
|
||||||
|
# which is exactly why upstream's `user: root` line is not repeated here: that
|
||||||
|
# line is commented in upstream as being "required for bind mounts", and this
|
||||||
|
# unit has none. The consequence is that the DB files are NOT readable by dank
|
||||||
|
# without `podman unshare` — accepted for a datastore nobody edits by hand;
|
||||||
|
# episode AUDIO is a host directory for the opposite reason (see the app unit).
|
||||||
|
#
|
||||||
|
# HEALTH. The base image is distroless: no shell, no curl, no wget, so no
|
||||||
|
# CMD-SHELL probe and no wget/curl probe is possible here (same class of unit
|
||||||
|
# as loki, whose probe had to move into the image). SurrealDB ships its own
|
||||||
|
# probe instead — `surreal is-ready` connects to ws://localhost:8000 by default
|
||||||
|
# and needs no credentials — verified against this digest: it prints `OK` and
|
||||||
|
# exits 0 while the server is up. Argument-only HealthCmd, like every unit on
|
||||||
|
# this host, because the quadlet generator splits it on spaces.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=SurrealDB (open-notebook datastore)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
ContainerName=open-notebook-surrealdb
|
||||||
|
Image=docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723
|
||||||
|
Pull=never
|
||||||
|
Network=open-notebook.network
|
||||||
|
EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env
|
||||||
|
EnvironmentFile=/home/dank/Containers/open-notebook/env
|
||||||
|
Exec=start --log info rocksdb:/mydata/mydatabase.db
|
||||||
|
Volume=open-notebook-db:/mydata
|
||||||
|
HealthCmd=/surreal is-ready
|
||||||
|
HealthInterval=30s
|
||||||
|
HealthTimeout=10s
|
||||||
|
HealthRetries=3
|
||||||
|
HealthStartPeriod=20s
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Quadlet unit: open-notebook (rootless, boot-persistent).
|
||||||
|
#
|
||||||
|
# Install path on dankclaw: ~/.config/containers/systemd/open-notebook.container
|
||||||
|
# Unit name: open-notebook.service (container name: open-notebook)
|
||||||
|
#
|
||||||
|
# WHAT IT IS. The fleet's podcast generator: notes and sources go in, a
|
||||||
|
# two-voice (up to four) episode MP3 comes out. One image runs THREE processes
|
||||||
|
# under supervisord — the FastAPI API on 5055, the background worker that
|
||||||
|
# writes and speaks the episode, and the Next.js UI on 8502 — so this one unit
|
||||||
|
# owns the whole app, and the frontend refuses to start until the API answers
|
||||||
|
# (the image's own wait-for-api.sh). Runbook: [[Open Notebook]].
|
||||||
|
#
|
||||||
|
# IMAGE. `lfnovo/open_notebook:v1-latest` pinned by manifest-list digest,
|
||||||
|
# resolved from the registry on 2026-10-18; it is the tag upstream's own
|
||||||
|
# docker-compose.yml pins for the multi-container shape. Do NOT reach for the
|
||||||
|
# `-single` tags: that variant bundles SurrealDB inside the app image, is
|
||||||
|
# deprecated upstream, and would give this host two databases. Pull=never, so
|
||||||
|
# the pin is fetched by the documented one-time step (docs: [[Open Notebook]]),
|
||||||
|
# which the deployer also runs when the image is absent:
|
||||||
|
#
|
||||||
|
# podman pull docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212
|
||||||
|
#
|
||||||
|
# NETWORKS, in this order on purpose. The generated run line keeps file order,
|
||||||
|
# so the DEFAULT ROUTE rides dankclaw_default: that is the network where this
|
||||||
|
# host has measured that a bridge container can reach a host-namespace
|
||||||
|
# listener through the gateway name (the Prometheus scrape of frank-zealot is
|
||||||
|
# the proof — see [[Container Network Namespaces]]), and frank-tts lives there
|
||||||
|
# too, so the TTS credential can be `http://frank-tts:7772/v1`. The private
|
||||||
|
# systemd-open-notebook bridge is second and carries only the database; its
|
||||||
|
# traffic is on-link, so losing the default route costs it nothing.
|
||||||
|
#
|
||||||
|
# PUBLISHED PORTS, and the 5055 collision. Upstream publishes 8502 and 5055,
|
||||||
|
# and 5055 is exactly the port seerr already holds on this host
|
||||||
|
# (`PublishPort=127.0.0.1:5055:5055`; `ss -ltn` on Dankclaw 2026-10-18 shows
|
||||||
|
# 127.0.0.1:5055 taken). So the API leaves the container on host port 5056.
|
||||||
|
# That forces one setting: the frontend auto-detects the API as
|
||||||
|
# `<host-you-opened>:5055`, so API_URL in scripts/open-notebook.env must name
|
||||||
|
# the remapped port explicitly, or the UI loads and every call fails. Both
|
||||||
|
# ports are published on all interfaces because the consumer is a browser on
|
||||||
|
# the LAN — the fleet's browser-facing services (open-webui, comfyui, gitea)
|
||||||
|
# bind the same way, while loopback-only is reserved for services whose user
|
||||||
|
# path is the guard chain. This service has NO vhost and no Caddy route: it is
|
||||||
|
# internal-only until it is guarded, and its own password
|
||||||
|
# (OPEN_NOTEBOOK_PASSWORD) is what stands in front of it. That password is not
|
||||||
|
# decoration: with it unset the API answers everyone on the LAN, including the
|
||||||
|
# unauthenticated episode-audio download.
|
||||||
|
#
|
||||||
|
# VOLUMES. `/app/data` is a named volume: uploads, the LangGraph SQLite
|
||||||
|
# checkpoints and the extraction caches the image keeps under it
|
||||||
|
# (UV_CACHE_DIR, HF_HOME) are app state nobody browses. The ONE exception is
|
||||||
|
# finished audio, which is bind-mounted out of the container at
|
||||||
|
# /home/dank/media/podcasts. That mount is interim and reversible: episode
|
||||||
|
# destination is an open human-only decision (Gitea issue #7, label
|
||||||
|
# `human-only` — agents must not work it), and until it lands, episodes are
|
||||||
|
# ordinary files on the host. DATA_FOLDER is hard-coded `./data` in the app
|
||||||
|
# (WORKDIR /app) and the DB stores audio paths RELATIVE to data/podcasts, so
|
||||||
|
# covering that one subdirectory is enough and removing the line changes
|
||||||
|
# nothing else. The image declares no USER, so the app runs as container root,
|
||||||
|
# which in this host's rootless mapping (0 -> 1000, measured on this host —
|
||||||
|
# see [[Seerr]]) writes those episode files as dank.
|
||||||
|
#
|
||||||
|
# HEALTH. `GET /health` on the API returns {"status":"healthy"} and is in the
|
||||||
|
# password middleware's excluded-path list, so the probe stays honest after the
|
||||||
|
# password is set (both read from api/main.py upstream). The image installs
|
||||||
|
# curl and ffmpeg in the runtime layer, so the probe needs no shell — kept
|
||||||
|
# argument-only for the generator's space-splitting. Note what this probe does
|
||||||
|
# NOT prove: the route returns a static body, so it says the API serves, not
|
||||||
|
# that the database answers. The DB has its own probe in its unit, and the
|
||||||
|
# worker's failure to reach the database shows up as failed jobs, not as an
|
||||||
|
# unhealthy container.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=open-notebook (podcast generator: API, worker, web UI)
|
||||||
|
After=network-online.target open-notebook-surrealdb.service
|
||||||
|
Wants=network-online.target open-notebook-surrealdb.service
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
ContainerName=open-notebook
|
||||||
|
Image=docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212
|
||||||
|
Pull=never
|
||||||
|
Network=dankclaw_default
|
||||||
|
Network=open-notebook.network
|
||||||
|
PublishPort=0.0.0.0:8502:8502
|
||||||
|
PublishPort=0.0.0.0:5056:5055
|
||||||
|
EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env
|
||||||
|
EnvironmentFile=/home/dank/Containers/open-notebook/env
|
||||||
|
Volume=open-notebook-data:/app/data
|
||||||
|
Volume=/home/dank/media/podcasts:/app/data/podcasts
|
||||||
|
HealthCmd=curl -fsS http://127.0.0.1:5055/health
|
||||||
|
HealthInterval=30s
|
||||||
|
HealthTimeout=10s
|
||||||
|
HealthRetries=3
|
||||||
|
HealthStartPeriod=60s
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
TimeoutStartSec=900
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Quadlet network: private bridge for the open-notebook pair (rootless).
|
||||||
|
#
|
||||||
|
# Install path on dankclaw: ~/.config/containers/systemd/open-notebook.network
|
||||||
|
# Unit name: open-notebook-network.service (podman network name:
|
||||||
|
# systemd-open-notebook — quadlet prefixes "systemd-", so probe the runtime
|
||||||
|
# name, never the filename; see [[Container Network Namespaces]]).
|
||||||
|
#
|
||||||
|
# WHY A PRIVATE BRIDGE. The app reaches its database by container name
|
||||||
|
# (SURREAL_URL=ws://open-notebook-surrealdb:8000/rpc), and container DNS names
|
||||||
|
# resolve only on the network that owns them. Putting SurrealDB here instead of
|
||||||
|
# on dankclaw_default keeps the datastore reachable ONLY to the container that
|
||||||
|
# owns it: 8000 is not published (an unpublished port is refused outright from
|
||||||
|
# every other namespace anyway), and nothing else on the fleet-wide network can
|
||||||
|
# dial it with the root credentials. Same reasoning as the gitea and authentik
|
||||||
|
# database pairs.
|
||||||
|
#
|
||||||
|
# Kernel bridge, not slirp4netns: the 2026-08-22 open-webui incident (a dead
|
||||||
|
# per-container userspace helper left a container that accepted TCP and reset
|
||||||
|
# every request for two days, logging nothing). A bridge has no such helper.
|
||||||
|
#
|
||||||
|
# Subnet registry on dankclaw (rootless):
|
||||||
|
# 10.88.0.0/16 default "podman" bridge
|
||||||
|
# 10.89.0.0/24 gitea (systemd-gitea)
|
||||||
|
# 10.89.1.0/24 compose stack (dankclaw_default)
|
||||||
|
# 10.89.2.0/24 searxng (systemd-searxng)
|
||||||
|
# 10.89.3.0/24 open-webui (systemd-open-webui)
|
||||||
|
# 10.89.4.0/24 open-notebook (systemd-open-notebook) <- this file
|
||||||
|
# Pinned so the container IPs stay deterministic across reboots, like the
|
||||||
|
# other pinned nets; 10.89.4.0/24 was the next unclaimed /24 in this registry.
|
||||||
|
|
||||||
|
[Network]
|
||||||
|
Driver=bridge
|
||||||
|
Subnet=10.89.4.0/24
|
||||||
|
Gateway=10.89.4.1
|
||||||
@@ -26,6 +26,7 @@ SERVICE_ORDER = (
|
|||||||
"marreta",
|
"marreta",
|
||||||
"pihole",
|
"pihole",
|
||||||
"open-webui",
|
"open-webui",
|
||||||
|
"open-notebook",
|
||||||
"gitea-postgres",
|
"gitea-postgres",
|
||||||
"gitea-server",
|
"gitea-server",
|
||||||
"comfyui",
|
"comfyui",
|
||||||
@@ -74,6 +75,7 @@ DANKCLAW_SERVICES = {
|
|||||||
"marreta",
|
"marreta",
|
||||||
"pihole",
|
"pihole",
|
||||||
"open-webui",
|
"open-webui",
|
||||||
|
"open-notebook",
|
||||||
"gitea-postgres",
|
"gitea-postgres",
|
||||||
"gitea-server",
|
"gitea-server",
|
||||||
"comfyui",
|
"comfyui",
|
||||||
@@ -316,6 +318,14 @@ def services_for_path(path: str, target: str) -> set[str]:
|
|||||||
if len(parts) >= 2 and parts[1] == "open-webui":
|
if len(parts) >= 2 and parts[1] == "open-webui":
|
||||||
_add(services, "open-webui")
|
_add(services, "open-webui")
|
||||||
return services
|
return services
|
||||||
|
if len(parts) >= 2 and parts[1] == "open-notebook":
|
||||||
|
# One deployable owns the app AND its SurrealDB, like the marreta
|
||||||
|
# app/companion pair: the app alone cannot write a note and the
|
||||||
|
# database alone is a volume nothing reads. Any change under the
|
||||||
|
# directory (unit, network, or the interim episode mount) therefore
|
||||||
|
# plans the pair, which deploy_open_notebook starts database-first.
|
||||||
|
_add(services, "open-notebook")
|
||||||
|
return services
|
||||||
# frank-dragoon left the quadlet shape on 2026-09-19 (CIRU v4.4.1 host
|
# frank-dragoon left the quadlet shape on 2026-09-19 (CIRU v4.4.1 host
|
||||||
# build, root unit): its files live in scripts/ and map there.
|
# build, root unit): its files live in scripts/ and map there.
|
||||||
if len(parts) >= 2 and parts[1] == "observability":
|
if len(parts) >= 2 and parts[1] == "observability":
|
||||||
@@ -426,6 +436,12 @@ def services_for_path(path: str, target: str) -> set[str]:
|
|||||||
# serving Qwen3.8-Flash-Next Q8_0. The download script fetches
|
# serving Qwen3.8-Flash-Next Q8_0. The download script fetches
|
||||||
# the payload, which is host-side state, so it selects nothing.
|
# the payload, which is host-side state, so it selects nothing.
|
||||||
_add(services, "frank-archon")
|
_add(services, "frank-archon")
|
||||||
|
if name == "open-notebook.env":
|
||||||
|
# open-notebook's tunables live here — the only repo-tracked part of
|
||||||
|
# either container's environment (the credentials are seeded on the
|
||||||
|
# host, so they are never a deploy trigger). Changing it is a
|
||||||
|
# restart of the pair, which the single deployable performs.
|
||||||
|
_add(services, "open-notebook")
|
||||||
# Deployment/test/benchmark scripts and other repo tooling do not
|
# Deployment/test/benchmark scripts and other repo tooling do not
|
||||||
# restart a runtime service.
|
# restart a runtime service.
|
||||||
return services
|
return services
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ service_is_allowed() {
|
|||||||
dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
||||||
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|\
|
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|\
|
||||||
dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\
|
dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\
|
||||||
|
dankclaw:open-notebook|\
|
||||||
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
||||||
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
||||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
||||||
@@ -646,6 +647,103 @@ deploy_open_webui() {
|
|||||||
systemctl --user restart open-webui.service
|
systemctl --user restart open-webui.service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# open-notebook is the fleet's podcast generator: one app container (API,
|
||||||
|
# worker and web UI under supervisord) in front of a SurrealDB container, with
|
||||||
|
# a private bridge between them. One handler owns the pair, because restarting
|
||||||
|
# the app without its database yields a UI that answers /health and fails every
|
||||||
|
# write; scripts/deploy-plan.py therefore maps every path of the pair here.
|
||||||
|
#
|
||||||
|
# Credentials follow the marreta convention (per-service file under the deploy
|
||||||
|
# user's home, mode 600, seeded from dankclaw/.env) with one difference: they
|
||||||
|
# are not optional. An empty OPEN_NOTEBOOK_PASSWORD is "authentication OFF",
|
||||||
|
# not "unconfigured" — the API, episode audio included, then answers anyone on
|
||||||
|
# the LAN — and an empty SURREAL_PASS lets the database come up with different
|
||||||
|
# root credentials than the app will present. So this handler refuses to start
|
||||||
|
# either unit until every key carries a value. An existing file is NEVER
|
||||||
|
# rewritten: regenerating an operator-set password would desync it from the
|
||||||
|
# credentials already stored in the database.
|
||||||
|
seed_open_notebook_env() {
|
||||||
|
local dest=/home/dank/Containers/open-notebook/env
|
||||||
|
local var missing=0
|
||||||
|
for var in OPEN_NOTEBOOK_ENCRYPTION_KEY OPEN_NOTEBOOK_PASSWORD SURREAL_PASSWORD SURREAL_PASS; do
|
||||||
|
grep -q "^${var}=." "$dest" 2>/dev/null || missing=1
|
||||||
|
done
|
||||||
|
if [[ "$missing" == 0 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ ! -e "$dest" ]]; then
|
||||||
|
if [[ ! -f dankclaw/.env ]]; then
|
||||||
|
echo "open-notebook: neither $dest nor dankclaw/.env exists; put OPEN_NOTEBOOK_ENCRYPTION_KEY, OPEN_NOTEBOOK_PASSWORD and SURREAL_PASSWORD in dankclaw/.env (see dankclaw/.env.example)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
mkdir -p /home/dank/Containers/open-notebook
|
||||||
|
{
|
||||||
|
awk -F= '/^OPEN_NOTEBOOK_ENCRYPTION_KEY=/ || /^OPEN_NOTEBOOK_PASSWORD=/ || /^SURREAL_PASSWORD=/ { print }' \
|
||||||
|
dankclaw/.env
|
||||||
|
# The `surreal` CLI reads SURREAL_PASS from the environment while
|
||||||
|
# the app reads SURREAL_PASSWORD. One value, written twice, so the
|
||||||
|
# pair can never be seeded with two different root passwords.
|
||||||
|
sed -n 's/^SURREAL_PASSWORD=/SURREAL_PASS=/p' dankclaw/.env
|
||||||
|
} > "$dest"
|
||||||
|
chmod 600 "$dest"
|
||||||
|
fi
|
||||||
|
for var in OPEN_NOTEBOOK_ENCRYPTION_KEY OPEN_NOTEBOOK_PASSWORD SURREAL_PASSWORD SURREAL_PASS; do
|
||||||
|
grep -q "^${var}=." "$dest" \
|
||||||
|
|| { echo "open-notebook: $dest carries no value for ${var}; fill it in directly (or in dankclaw/.env before the first seed) — a blank value here means an unauthenticated service or a mismatched database password" >&2; return 1; }
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Both units ship Pull=never: the digest pins in the .container files are the
|
||||||
|
# contract, and the generator must never re-resolve a moving tag at start. That
|
||||||
|
# makes fetching a pin a precondition the deployer satisfies once per pin
|
||||||
|
# change; the existence check keeps every later re-deploy offline-safe.
|
||||||
|
ensure_pinned_image() {
|
||||||
|
local image="$1"
|
||||||
|
if podman image exists "$image"; then
|
||||||
|
echo "open-notebook: $image is already in local storage"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "open-notebook: pulling pinned image $image"
|
||||||
|
podman pull "$image"
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy_open_notebook() {
|
||||||
|
seed_open_notebook_env || return 1
|
||||||
|
|
||||||
|
ensure_pinned_image 'docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723' || return 1
|
||||||
|
ensure_pinned_image 'docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212' || return 1
|
||||||
|
|
||||||
|
# install_quadlet copies the sibling open-notebook.network with the app
|
||||||
|
# unit. That cp is load-bearing, not tidiness: when a referenced Network=
|
||||||
|
# file is absent the v1 generator refuses to produce the container unit at
|
||||||
|
# all ("requested Quadlet unit open-notebook.network was not found").
|
||||||
|
install_quadlet open-notebook-surrealdb dankclaw/open-notebook/quadlet open-notebook-surrealdb
|
||||||
|
install_quadlet open-notebook dankclaw/open-notebook/quadlet open-notebook
|
||||||
|
systemctl --user start open-notebook-network.service
|
||||||
|
|
||||||
|
# Interim episode destination (Gitea issue #7, `human-only`): finished
|
||||||
|
# episode audio is bind-mounted out to this host directory. Created here as
|
||||||
|
# the deploy user so the container's mapped root writes files dank can
|
||||||
|
# read, and so the dependency is explicit rather than podman's default.
|
||||||
|
mkdir -p /home/dank/media/podcasts
|
||||||
|
|
||||||
|
# No `systemctl --user enable`: generated Quadlet units cannot be enabled
|
||||||
|
# on this host (see deploy_authentik); boot persistence is the [Install]
|
||||||
|
# section inside each .container file. Database first, and both on every
|
||||||
|
# run — a stale app left attached to a recreated database is exactly the
|
||||||
|
# half-deployed state this handler exists to prevent.
|
||||||
|
systemctl --user restart open-notebook-surrealdb.service
|
||||||
|
wait_container_healthy open-notebook-surrealdb 90 || return 1
|
||||||
|
systemctl --user restart open-notebook.service
|
||||||
|
# The gate is the API's own health route: upstream serves `GET /health` on
|
||||||
|
# 5055 with {"status":"healthy"} and exempts it from the password
|
||||||
|
# middleware, so it stays a real answer once OPEN_NOTEBOOK_PASSWORD is set.
|
||||||
|
# Host port 5056, because seerr already holds 127.0.0.1:5055. The UI on
|
||||||
|
# 8502 is deliberately not gated: supervisord starts it only after the API
|
||||||
|
# answers (the image's wait-for-api.sh), so this gate already implies it.
|
||||||
|
wait_for_health open-notebook http://127.0.0.1:5056/health 180
|
||||||
|
}
|
||||||
|
|
||||||
deploy_comfyui() {
|
deploy_comfyui() {
|
||||||
# Build first: the unit's Image= resolves against local storage, so the
|
# Build first: the unit's Image= resolves against local storage, so the
|
||||||
# image has to exist before the generator starts a container from it.
|
# image has to exist before the generator starts a container from it.
|
||||||
@@ -1573,6 +1671,7 @@ deploy_service() {
|
|||||||
gluetun) deploy_gluetun ;;
|
gluetun) deploy_gluetun ;;
|
||||||
searxng-valkey) deploy_searxng_valkey ;;
|
searxng-valkey) deploy_searxng_valkey ;;
|
||||||
open-webui) deploy_open_webui ;;
|
open-webui) deploy_open_webui ;;
|
||||||
|
open-notebook) deploy_open_notebook ;;
|
||||||
pihole) deploy_pihole ;;
|
pihole) deploy_pihole ;;
|
||||||
gitea-postgres) deploy_gitea_unit gitea-postgres gitea-postgres ;;
|
gitea-postgres) deploy_gitea_unit gitea-postgres gitea-postgres ;;
|
||||||
gitea-server) deploy_gitea_unit gitea-server gitea-server ;;
|
gitea-server) deploy_gitea_unit gitea-server gitea-server ;;
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ valid_service() {
|
|||||||
dankclaw:alertmanager|\
|
dankclaw:alertmanager|\
|
||||||
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
||||||
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\
|
dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\
|
||||||
|
dankclaw:open-notebook|\
|
||||||
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
||||||
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
||||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# open-notebook.env — configuration for **open-notebook**, the fleet's podcast
|
||||||
|
# generator on Dankclaw (UI host port **8502**, API host port **5056**). Read by
|
||||||
|
# BOTH Quadlet units (dankclaw/open-notebook/quadlet/) as the FIRST
|
||||||
|
# EnvironmentFile= line, which is why every path and host name below is the
|
||||||
|
# value the CONTAINER must see.
|
||||||
|
#
|
||||||
|
# TWO FILES, ONE ORDER. This file is tracked; it holds knobs, not credentials.
|
||||||
|
# The three secret keys below are declared EMPTY on purpose — the `scripts/*.env`
|
||||||
|
# gitignore exception exists for "paths and numbers, never credentials" — and the
|
||||||
|
# real values live in the host file the deployer seeds at
|
||||||
|
# /home/dank/Containers/open-notebook/env (mode 600, seeded from dankclaw/.env,
|
||||||
|
# the same convention grafana/marreta/pihole follow). Both files are handed to
|
||||||
|
# podman in EnvironmentFile= order and the LAST one wins (verified against the
|
||||||
|
# generated run line), so the host file overrides these blanks. Do not reorder
|
||||||
|
# the two lines.
|
||||||
|
#
|
||||||
|
# WHY THE DEPLOYER REFUSES TO START THESE UNITS WITHOUT THE SECRET FILE. An empty
|
||||||
|
# OPEN_NOTEBOOK_PASSWORD is not "unconfigured", it is **authentication off**, and
|
||||||
|
# the episode-audio download is one of the routes the API serves without a
|
||||||
|
# session; an empty SURREAL_PASS lets the database come up with different root
|
||||||
|
# credentials than the app will present; an empty OPEN_NOTEBOOK_ENCRYPTION_KEY
|
||||||
|
# means no provider credential can be saved at all. So the guard is in
|
||||||
|
# deploy-remote.sh (seed_open_notebook_env), not in the app's opinion.
|
||||||
|
#
|
||||||
|
# THE CPU-SHUTDOWN GUARD (the two knobs that matter most on this box). Defaults
|
||||||
|
# are TTS_BATCH_SIZE=5 and OPEN_NOTEBOOK_WORKER_MAX_TASKS=5 — five parallel
|
||||||
|
# synthesis requests from five parallel jobs. frank-tts answers each request by
|
||||||
|
# spawning its own engine process (~8 GiB resident, 8 threads, measured in the
|
||||||
|
# frank-tts CPU trial of 2026-10-18), so the defaults mean ~40 GiB and forty
|
||||||
|
# threads aimed at a machine that also serves frank-dragoon and frank-zealot and
|
||||||
|
# whose TTS engine runs on the SAME CPU. Both numbers are 1 here: one episode at
|
||||||
|
# a time, one voice line synthesized at a time. That is a throughput choice, not
|
||||||
|
# a correctness one — a podcast is a background job — and it is the difference
|
||||||
|
# between a slow render and a box that stops answering the text endpoints
|
||||||
|
# mid-sentence. Raise them only with a TTS engine that batches internally.
|
||||||
|
#
|
||||||
|
# THE TTS CLOCK. ESPERANTO_TTS_TIMEOUT is the worker's patience per DIALOGUE
|
||||||
|
# LINE, not per episode: one episode is dozens of POSTs to /v1/audio/speech
|
||||||
|
# (the app synthesises each speaker turn separately and concatenates the clips
|
||||||
|
# with ffmpeg afterwards), and a CPU engine takes single-digit seconds for a
|
||||||
|
# line, with the tail of a long turn well past the 300 s default. 660 sits one
|
||||||
|
# round-trip OUTSIDE SHIM_TIMEOUT=600 in scripts/frank-tts.env — the shim's own
|
||||||
|
# per-request ceiling — so a frank-tts give-up arrives as frank-tts's error and
|
||||||
|
# not as open-notebook abandoning a synthesis still in flight. An abandoned
|
||||||
|
# request does not cancel the engine process; it just leaves the episode stuck in
|
||||||
|
# "generating" while the audio arrives.
|
||||||
|
#
|
||||||
|
# THE SCRIPTWRITER. ESPERANTO_LLM_TIMEOUT is the app's own default (180 s), kept
|
||||||
|
# visible beside the TTS number because both belong to one podcast run: the
|
||||||
|
# outline call and the transcript call are the slow text work, and frank-zealot
|
||||||
|
# answers them well inside 180. Provider choice is made in the UI, not here —
|
||||||
|
# see [[Open Notebook]] for the one-time credential provisioning — and it is
|
||||||
|
# frank-zealot on purpose: script writing asks for a JSON-schema response, which
|
||||||
|
# the OpenAI-compatible llama.cpp endpoint is known to serve, while halogen's
|
||||||
|
# structured-output behaviour on frank-dragoon is UNVERIFIED. halogen remains
|
||||||
|
# the unproven alternative, not the fallback.
|
||||||
|
#
|
||||||
|
# THE DATABASE COORDINATES. SURREAL_URL addresses the sibling container BY NAME
|
||||||
|
# on systemd-open-notebook (container DNS is scoped to the owning network, so
|
||||||
|
# this is the only form that works). NAMESPACE/DATABASE/USER are plain names.
|
||||||
|
# SURREAL_PASSWORD and SURREAL_PASS are ONE value under two spellings: the app
|
||||||
|
# reads SURREAL_PASSWORD, the `surreal` CLI reads SURREAL_PASS. Keep them equal;
|
||||||
|
# the deployer writes both into the seeded file from a single SURREAL_PASSWORD in
|
||||||
|
# dankclaw/.env, so hand-editing one line is the only way to desync them.
|
||||||
|
|
||||||
|
SURREAL_URL=ws://open-notebook-surrealdb:8000/rpc
|
||||||
|
SURREAL_NAMESPACE=open_notebook
|
||||||
|
SURREAL_DATABASE=open_notebook
|
||||||
|
SURREAL_USER=open_notebook
|
||||||
|
# Secrets — filled on the host, never here (see the header).
|
||||||
|
SURREAL_PASSWORD=
|
||||||
|
SURREAL_PASS=
|
||||||
|
OPEN_NOTEBOOK_ENCRYPTION_KEY=
|
||||||
|
OPEN_NOTEBOOK_PASSWORD=
|
||||||
|
|
||||||
|
# WHERE THE BROWSER FINDS THE API. The UI auto-detects `<host-you-opened>:5055`,
|
||||||
|
# and 5055 on this host belongs to seerr, so the API leaves the container on 5056
|
||||||
|
# and this line has to say so. Both origins are listed in CORS_ORIGINS because
|
||||||
|
# the API is called cross-origin from the UI's own port: the LAN origin is the
|
||||||
|
# normal one, the localhost one covers a browser opened on Dankclaw itself. The
|
||||||
|
# app logs a warning at start while CORS_ORIGINS is unset and defaults to `*`,
|
||||||
|
# which is a wildcard in front of an endpoint that serves every episode's audio.
|
||||||
|
API_URL=http://192.168.0.143:5056
|
||||||
|
CORS_ORIGINS=http://192.168.0.143:8502,http://localhost:8502
|
||||||
|
|
||||||
|
# Concurrency: see "THE CPU-SHUTDOWN GUARD" above. Defaults are 5 and 5.
|
||||||
|
TTS_BATCH_SIZE=1
|
||||||
|
OPEN_NOTEBOOK_WORKER_MAX_TASKS=1
|
||||||
|
|
||||||
|
# Timeouts: see "THE TTS CLOCK" and "THE SCRIPTWRITER" above.
|
||||||
|
ESPERANTO_TTS_TIMEOUT=660
|
||||||
|
ESPERANTO_LLM_TIMEOUT=180
|
||||||
|
|
||||||
|
# Heavy extraction runtimes stay OFF, which is also what keeps boot fast: each
|
||||||
|
# one installs a multi-gigabyte ML/Chromium stack into the image's venv on first
|
||||||
|
# startup (cached on the /app/data volume after that). Podcast generation reads
|
||||||
|
# notes and sources that are already text, so neither is needed here; enable one
|
||||||
|
# deliberately, per the upstream docs, if source ingestion starts to matter.
|
||||||
|
# OPEN_NOTEBOOK_ENABLE_DOCLING=true
|
||||||
|
# OPEN_NOTEBOOK_ENABLE_CRAWL4AI=true
|
||||||
@@ -128,6 +128,16 @@ for path in "${CHANGED[@]}"; do
|
|||||||
add_test tests/docs_completeness.bats
|
add_test tests/docs_completeness.bats
|
||||||
add_test tests/deployment_scoping.bats
|
add_test tests/deployment_scoping.bats
|
||||||
;;
|
;;
|
||||||
|
# open-notebook is the fleet's podcast generator: an app quadlet, the
|
||||||
|
# SurrealDB quadlet behind it, the private network between them, and the
|
||||||
|
# one env file both units read. Like the caddy block above, this case
|
||||||
|
# has to precede the dankclaw/*.container glob, whose `*` crosses `/`
|
||||||
|
# and would otherwise swallow dankclaw/open-notebook/quadlet/*.
|
||||||
|
dankclaw/open-notebook/*|scripts/open-notebook.env)
|
||||||
|
add_test tests/open_notebook.bats
|
||||||
|
add_test tests/docs_completeness.bats
|
||||||
|
add_test tests/deployment_scoping.bats
|
||||||
|
;;
|
||||||
dankclaw/*.container|dankclaw/*.network)
|
dankclaw/*.container|dankclaw/*.network)
|
||||||
add_test tests/open_webui_quadlet.bats
|
add_test tests/open_webui_quadlet.bats
|
||||||
add_test tests/pihole.bats
|
add_test tests/pihole.bats
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
---
|
||||||
|
type: reference
|
||||||
|
description: "open-notebook is the fleet's podcast generator on Dankclaw: notes and sources in, a multi-speaker episode out — script written by frank-zealot, voices rendered by frank-tts, no cloud provider anywhere in the path."
|
||||||
|
tags:
|
||||||
|
- dankclaw
|
||||||
|
- open-notebook
|
||||||
|
- podcasts
|
||||||
|
- quadlet
|
||||||
|
timestamp: 2026-10-18
|
||||||
|
---
|
||||||
|
|
||||||
|
# Open Notebook
|
||||||
|
|
||||||
|
open-notebook ([lfnovo/open-notebook](https://github.com/lfnovo/open-notebook)) is the notebook layer that turns existing notes and sources into a **spoken episode**: an outline, then a dialogue transcript, then one synthesized clip per speaker turn, concatenated with ffmpeg into an MP3. In the fleet it is the consumer that closes the loop Frank's audio stack was built for — the script is written by **frank-zealot**, the voices are rendered by **frank-tts**, and nothing leaves the LAN.
|
||||||
|
|
||||||
|
**It is NOT deployed.** This page documents repo-owned configuration — the Quadlet units, the env file, the deployer path, the homepage card — and the contract those files were written against. On 2026-10-18 no `open-notebook` or `open-notebook-surrealdb` container exists on Dankclaw (`podman ps -a` census, 2026-10-18), and the homepage card ships `wip: "down"` with `showLink: false`, so the card is hidden and reported `wip-down`. Deploying it is a deliberate next step, not a pending one.
|
||||||
|
|
||||||
|
**Host:** Dankclaw (rootless Podman, user `dank`) — **Ports:** 8502 UI and 5056 API, published on the LAN, **no vhost and no Caddy route** — **Deployer:** `open-notebook` (one service, two units) — **Docs:** https://github.com/lfnovo/open-notebook
|
||||||
|
|
||||||
|
## Facts (verified 2026-10-18 against the pinned images, upstream source, and the live host)
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|------|-------|
|
||||||
|
| App image | `docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212` (`open-notebook.container:79`) — the tag upstream's own `docker-compose.yml:27` pins for the multi-container shape. `v1-latest` is a moving tag, so the manifest-list digest (resolved from the registry 2026-10-18) is what makes this reproducible |
|
||||||
|
| Database image | `docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723` (`open-notebook-surrealdb.container:66`) — that digest is **SurrealDB 2.7.0** (image built 2026-09-23; measured by running the digest: `surreal version`) |
|
||||||
|
| Do NOT use | the `-single` tags (`v1-latest-single`). That variant bundles SurrealDB inside the app image and is deprecated upstream; here it would give the host a second, hidden database |
|
||||||
|
| Ports | container 8502 (Next.js UI) + 5055 (FastAPI API) → published `0.0.0.0:8502:8502` and `0.0.0.0:5056:5055` (`open-notebook.container:83-84`). **Host 5055 is taken**: seerr publishes `127.0.0.1:5055` (`dankclaw/seerr/quadlet/seerr.container`, and `ss -ltn` on Dankclaw shows `127.0.0.1:5055` live, 2026-10-18). The API therefore leaves on 5056, which is what forces `API_URL` (below) |
|
||||||
|
| Database port | 8000 inside its own namespace, **never published** (`open-notebook-surrealdb.container` has no `PublishPort`). Upstream publishes `127.0.0.1:8000` for debugging (`docker-compose.yml:18`); this unit does not, and the seam asserts that it stays that way |
|
||||||
|
| Data | `open-notebook-data:/app/data` (named volume: uploads, LangGraph SQLite checkpoints, extraction caches) **plus** the interim host bind `/home/dank/media/podcasts:/app/data/podcasts` (`open-notebook.container:87-88`); database in the `open-notebook-db` named volume |
|
||||||
|
| Networks | app: `dankclaw_default` first, then the private `systemd-open-notebook` (10.89.4.0/24, `open-notebook.network:33`); database: the private bridge only. Order is load-bearing — the generated `--network` flags keep file order, so the default route rides the network where `host.containers.internal` is proven |
|
||||||
|
| Units | `dankclaw/open-notebook/quadlet/open-notebook.container` → `open-notebook.service`; `open-notebook-surrealdb.container` → `open-notebook-surrealdb.service`; both `Restart=always`, `RestartSec=10` / `5`, `[Install] WantedBy=default.target` — boot-persistent per [[Quadlet Lifecycle]] (generated units cannot be `enable`d on this host; the `[Install]` section is what the v1 generator honors at user-manager start) |
|
||||||
|
| Health | app: `curl -fsS http://127.0.0.1:5055/health` (`open-notebook.container:89`); database: `/surreal is-ready` (`open-notebook-surrealdb.container:73`) |
|
||||||
|
| Env | `scripts/open-notebook.env` (repo, knobs) then `/home/dank/Containers/open-notebook/env` (host, secrets, mode 600) — two `EnvironmentFile=` lines per unit, repo file **first** because the last file wins |
|
||||||
|
| Deployer | `deploy_open_notebook` in `scripts/deploy-remote.sh`: seed secrets → ensure both digest pins are in local storage → install both units + the network → `mkdir -p /home/dank/media/podcasts` → restart database, wait for its container health → restart app → `wait_for_health open-notebook http://127.0.0.1:5056/health 180` |
|
||||||
|
|
||||||
|
## What the image actually runs
|
||||||
|
|
||||||
|
One container, three processes under supervisord (`supervisord.conf` upstream): `uvicorn api.main:app --port 5055` (`:12`), the job worker (`:25`), and the Next.js frontend (`:39`). Two consequences the config depends on:
|
||||||
|
|
||||||
|
- **One unit owns the whole app.** There is no separate worker or frontend container to scale or restart, and the frontend refuses to start until the API answers (`wait-for-api.sh && node server.js`, `supervisord.conf:39`) — so gating the API gates the UI.
|
||||||
|
- **The worker's concurrency comes from the environment**, not a flag baked into the image: `--max-tasks ${OPEN_NOTEBOOK_WORKER_MAX_TASKS:-5}` (`supervisord.conf:25`). Setting it in the env file is a real change, not decoration.
|
||||||
|
|
||||||
|
Also from the image itself: `WORKDIR /app` and `EXPOSE 8502 5055` (`Dockerfile:84`, `:126`), **no `USER` line** (the app runs as container root, which in this host's rootless map lands as `dank` — the [[Seerr]] UID note measured `0 → 1000` on this machine), and `curl` **and** `ffmpeg` installed in the runtime layer (`Dockerfile:74-76`) — curl makes the health probe possible without a shell wrapper, ffmpeg is how the per-line clips become one file.
|
||||||
|
|
||||||
|
`DATA_FOLDER` is hard-coded `"./data"` (`open_notebook/config.py:5`) and `PODCASTS_FOLDER` is `"./data/podcasts"` (`:19`); the database stores each episode's audio path **relative** to that folder (`episodes/<uuid>/audio/<uuid>.mp3`), and the download route is `GET /api/podcasts/episodes/{id}/audio`. That is why covering one subdirectory with a bind mount is sufficient — nothing in the database has to change if the mount moves.
|
||||||
|
|
||||||
|
## Provisioning the providers (after first boot; UI or REST, never a unit)
|
||||||
|
|
||||||
|
Credentials are stored in the database, encrypted, and are **not expressible in env or a Quadlet file**. The sequence is one-time, in **Manage → Models**, and both entries use provider type **OpenAI Compatible** (upstream: any server that implements `/v1/audio/speech` works this way — `docs/5-CONFIGURATION/local-tts.md:5`).
|
||||||
|
|
||||||
|
1. **TTS credential** — base URL **`http://frank-tts:7772/v1`**. The `/v1` is part of the URL: the client posts to `<base_url>/audio/speech`, so a base URL without `/v1` 404s on every dialogue line (`local-tts.md:72-80`, the base-URL table, always carries `/v1`). `frank-tts` resolves by container name because the app is attached to `dankclaw_default`, the network frank-tts's unit joins (`dankclaw/voiceservice/quadlet/frank-tts.container:51-52`). If frank-tts is ever moved off that network, the published-port form is `http://192.168.0.143:7772/v1` — a published port is dialable at the host's LAN address from any netns. The API-key field stays empty; frank-tts does not authenticate.
|
||||||
|
2. **LLM credential** — base URL **`http://host.containers.internal:7771/v1`**, model row `frank-zealot`. frank-zealot is a **host process** on `0.0.0.0:7771` (`ss -ltn` on Dankclaw shows the listener with no container owning it, 2026-10-18), and on this netavark host a bridge container reaches the host's LAN IP only at a *published* port — which is why Prometheus scrapes the same endpoint as `host.containers.internal:7771` (`shared/prometheus/prometheus.yml:89-111`, "not their own LAN addresses"). See [[Container Network Namespaces]]. `http://192.168.0.143:7771/v1` is the variant to try only if the gateway name ever stops resolving; it is expected to be refused from a bridge netns.
|
||||||
|
3. **Model rows are created by name.** Discovery is optional (see the next section): type the model id and confirm the add. One base URL per credential, so the TTS and LLM credentials are necessarily two separate configurations.
|
||||||
|
|
||||||
|
**Verify reachability before saving**, from inside the container, because both addresses are netns-dependent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman exec open-notebook curl -sS http://frank-tts:7772/health
|
||||||
|
podman exec open-notebook curl -sS http://host.containers.internal:7771/v1/models
|
||||||
|
```
|
||||||
|
|
||||||
|
**The blocker to state plainly:** frank-tts is built, named and tested but **not deployed** — nothing answers on 7772 (`ss -ltn`, Dankclaw, 2026-10-18; [[Local TTS and Voice Cloning]] records the same census). open-notebook deployed today writes scripts and fails at the first voice line. frank-tts ships first, or podcast generation waits.
|
||||||
|
|
||||||
|
**Why frank-zealot and not frank-dragoon:** podcast scriptwriting asks the model for a JSON-schema response (with a plain-JSON fallback). llama.cpp's OpenAI-compatible endpoint is a known-good target for that; frank-dragoon's halogen runtime has **unverified** structured-output behaviour, so it is the unproven alternative, not the fallback. `ESPERANTO_LLM_TIMEOUT=180` (the app's own default) covers an outline or transcript call on zealot comfortably; upstream's note is to keep it under 600 so a slow call fails with a clear error instead of a UI timeout.
|
||||||
|
|
||||||
|
## Speakers and voices: 1–4, and the IDs are free text
|
||||||
|
|
||||||
|
A **speaker profile** carries one to four speakers (`docs/3-USER-GUIDE/creating-podcasts.md:27`), each with a personality and a **Voice ID that is typed exactly and passed through to the TTS request untouched** (`:29`) — there is no whitelist, no enum, no validation against a voice list. So the fleet's voice-pack names work verbatim:
|
||||||
|
|
||||||
|
| Speaker | Voice ID to type |
|
||||||
|
|---------|------------------|
|
||||||
|
| Speaker 1 | `narrator1` |
|
||||||
|
| Speaker 2 | `narrator2` |
|
||||||
|
| Speaker 3 | `narrator3` |
|
||||||
|
|
||||||
|
Those are the pack names frank-tts resolves from `/home/dank/voices` (`scripts/frank-tts.env` header: a voice is real exactly when `/voices/<name>/reference.wav` exists). Two traps, both from upstream's shipped defaults:
|
||||||
|
|
||||||
|
- The three seeded speaker profiles carry **OpenAI voice names** (`nova`, `alloy`, …) and **no voice model at all** — they show a "Setup required" badge, and generation fails with a voice error until they are edited (`creating-podcasts.md:13`, `:33`; `local-tts.md:89`). Add a `narrator1`/`narrator2` pair (or a third/fourth) rather than editing the seeded ones in place.
|
||||||
|
- Two speakers with the same voice are hard to follow (`creating-podcasts.md:89`). Distinct packs per speaker is the point of the 1–4 shape.
|
||||||
|
|
||||||
|
## Two buttons that fail against frank-tts, and why that is cosmetic
|
||||||
|
|
||||||
|
- **Test Connection** probes `GET <base_url>/models` (`open_notebook/ai/connection_tester.py:208-229`). frank-tts serves exactly two GET routes — `/health` and `/v1/audio/voices` — and 404s every other path (`dankclaw/voiceservice/app.py:228-238`). The button reports a failure against a healthy server.
|
||||||
|
- **Discover Models** does the same `GET <base>/models` (`open_notebook/ai/model_discovery.py:26-28`) and finds nothing to list.
|
||||||
|
- The TTS test path also synthesises with a **hard-coded voice `alloy`** for `openai_compatible` (`connection_tester.py:328-336`), which is not a fleet voice pack and would be refused even if the probe reached frank-tts.
|
||||||
|
|
||||||
|
None of it blocks anything: model rows are added **by name** ("If a model isn't listed, type its id and click Add", `local-tts.md:82`) and the Voice ID field is free text. The failure is a missing `/models` route on a deliberately minimal shim, not a broken credential. The honest end-to-end test is a real generation job, or the `podman exec … curl /v1/audio/speech` call frank-tts's own seam documents.
|
||||||
|
|
||||||
|
## The CPU guard, and why both numbers are 1
|
||||||
|
|
||||||
|
Defaults are `TTS_BATCH_SIZE=5` and `OPEN_NOTEBOOK_WORKER_MAX_TASKS=5` (`docs/5-CONFIGURATION/environment-reference.md:129-130`) — five parallel synthesis requests from five parallel jobs. frank-tts spawns an engine process per request (about 8 GiB resident, 8 threads, from the frank-tts CPU trial of 2026-10-18; `scripts/frank-tts.env` pins `LLAMA_TTS_THREADS=8`), on a machine that also serves frank-dragoon and frank-zealot. Five of those is ~40 GiB and forty threads competing with two LLM endpoints for the same cores. `scripts/open-notebook.env:87-88` sets both to 1: one episode at a time, one voice line at a time. Upstream's own guidance is the same direction ("Set 1 for a local model on a single GPU", "Lower it … for rate-limited or single-threaded TTS servers").
|
||||||
|
|
||||||
|
The clock next to it: `ESPERANTO_TTS_TIMEOUT` (`:91`) is patience **per dialogue line**, not per episode — a three-minute episode is dozens of separate POSTs, and the tail of a long turn on a CPU engine goes past upstream's 300 s default. It is set to **660, one round-trip outside `SHIM_TIMEOUT=600`** in `scripts/frank-tts.env` (frank-tts's own per-request ceiling): nested budgets go inside-out — the same rule that file states for `LLAMA_TIMEOUT` — so a frank-tts give-up arrives labelled as frank-tts's error instead of the worker's own timeout, and re-measuring frank-tts cannot silently strand this number below it. An abandoned request does not cancel the engine process; it only strands the episode in "generating".
|
||||||
|
|
||||||
|
## Secrets, and the difference between blank and unset
|
||||||
|
|
||||||
|
`OPEN_NOTEBOOK_PASSWORD` unset means **authentication off** (`environment-reference.md:46`) — every API route, including the episode-audio download, answers anyone who can reach the published port. That is a real exposure on a LAN-published service with no vhost and no guard chain in front of it, which is why `deploy_open_notebook` refuses to start either unit unless the seeded host file carries a value for all four secret keys (`OPEN_NOTEBOOK_ENCRYPTION_KEY`, `OPEN_NOTEBOOK_PASSWORD`, `SURREAL_PASSWORD`, `SURREAL_PASS`). `OPEN_NOTEBOOK_ENCRYPTION_KEY` is separately load-bearing: it is **required to save any provider credential**, and changing it later makes stored keys undecryptable (`:44`).
|
||||||
|
|
||||||
|
The tracked file declares the secrets **empty** and the host file supplies them; podman applies `--env-file` arguments in order and the last one wins (verified against the generated `ExecStart` line, 2026-10-18). Reordering the two `EnvironmentFile=` lines in a unit would blank every credential — that is the trap the seam's ordering assertion exists for.
|
||||||
|
|
||||||
|
The database has the same trap in a different spelling: the app reads `SURREAL_PASSWORD`, the `surreal` CLI reads `SURREAL_PASS` (its `start --help` prints `[env: SURREAL_PASS=]`; measured against the pinned digest). One value, written twice, by the seed function — so the pair cannot be given different root passwords. Both arrive through the environment, **not** through `Exec=`: `--pass` on the command line would put the database password in `/proc/<pid>/cmdline`, readable by any `ps` on the host. Measured with the pinned image: env-only credentials produce `Initialising credentials user=…`, the root user is created on first boot, a correct login authenticates and a wrong password is refused — no `--user`/`--pass` flags needed, and no `--bind` either, because the image's own `SURREAL_BIND=0.0.0.0:8000` is what makes the RPC reachable from another container at all.
|
||||||
|
|
||||||
|
## Episode destination: interim, and reversible (issue #7)
|
||||||
|
|
||||||
|
**Episode files currently exist in two places by design.** The app's own state stays in the `open-notebook-data` named volume; finished audio additionally lands on the host, because `/home/dank/media/podcasts` is bind-mounted over `/app/data/podcasts` (`open-notebook.container:88`). That is the **interim** answer to where episodes live: the destination is an open decision tracked as **Gitea issue #7, labelled `human-only` — agents must not work it.** Until Frank settles it, episode MP3s are ordinary files in his home directory, readable without `podman unshare`, which the database volume would require (measured with the pinned SurrealDB image).
|
||||||
|
|
||||||
|
Reversibility is one line: delete the `Volume=/home/dank/media/podcasts:/app/data/podcasts` line and the episodes go back to living solely inside the named volume. Nothing else in the config assumes the bind. See [[Storage Policy]] for the repo's storage conventions this interim state deliberately bends.
|
||||||
|
|
||||||
|
## Deploying, and the digest-pull step
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# on Dankclaw, as dank — the pins are Pull=never, so fetch them first
|
||||||
|
podman pull docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723
|
||||||
|
podman pull docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy_open_notebook` runs the same two pulls when the image is absent from local storage, so the manual step is only needed to pre-seed an offline host or to bump a pin. Then `scripts/deploy.sh --target dankclaw --service open-notebook`. Both `Pull=never` and the pin comment live in the unit files; upgrading means **editing the digest**, not repulling a tag — see [[Deployment Flow]].
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
- **Nothing deployed (today's state):** revert the commits. There is no host state to undo — no unit, no container, no volume, no `/home/dank/media/podcasts` directory beyond what the deployer would create.
|
||||||
|
- **Deployed:** `systemctl --user stop open-notebook.service open-notebook-surrealdb.service`, then move the three files (`open-notebook.container`, `open-notebook-surrealdb.container`, `open-notebook.network`) out of `~/.config/containers/systemd/` into `~/.AGENTTRASH/` — never `rm` — and `systemctl --user daemon-reload`. The generated units disappear with their sources.
|
||||||
|
- **Kept on purpose:** the `open-notebook-data` and `open-notebook-db` named volumes and `/home/dank/media/podcasts`. Episode audio and the notebook database are user data; removing them is a separate, deliberate act, and episode files are host files precisely so they can outlive the container.
|
||||||
|
- **Homepage:** the card is `wip: "down"` + `showLink: false`, so retiring the service needs no card surgery beyond setting it back to that state.
|
||||||
|
|
||||||
|
## Citations
|
||||||
|
|
||||||
|
- Repo: `dankclaw/open-notebook/quadlet/{open-notebook.container,open-notebook-surrealdb.container,open-notebook.network}`, `scripts/open-notebook.env`, `dankclaw/.env.example`, `scripts/deploy-plan.py`, `scripts/deploy-remote.sh` (`seed_open_notebook_env`, `ensure_pinned_image`, `deploy_open_notebook`), `scripts/deploy.sh`, `scripts/test-changed.sh`, `tests/open_notebook.bats`, `shared/homepage/services.json`, `dankclaw/seerr/quadlet/seerr.container` (the 5055 collision), `dankclaw/voiceservice/{quadlet/frank-tts.container,app.py}`, `scripts/frank-tts.env`, `shared/prometheus/prometheus.yml`
|
||||||
|
- Upstream `lfnovo/open-notebook` @ main, read 2026-10-18: `docker-compose.yml:3,10,11,18,27,29-30,39-43`, `Dockerfile:69-76,84,126,140,158`, `supervisord.conf:12,23-25,39`, `api/main.py:73,227-230,239-241,414`, `api/credentials_service.py:418-420,552`, `api/routers/credentials.py:426-429,433-437`, `open_notebook/config.py:5,19`, `open_notebook/podcasts/audio_paths.py`, `open_notebook/ai/connection_tester.py:208-238,326-336`, `open_notebook/ai/model_discovery.py:26-28`, `docs/3-USER-GUIDE/creating-podcasts.md:13,27,29,33,89`, `docs/5-CONFIGURATION/environment-reference.md:44,46,106,110,129,130`, `docs/5-CONFIGURATION/local-tts.md:5,45,72-82,89`
|
||||||
|
- Registry (2026-10-18): `lfnovo/open_notebook` tags (`v1-latest`, `latest`, the `-single` set) and manifest-list digests; `surrealdb/surrealdb` `v2` → 2.7.0
|
||||||
|
- Local measurements (2026-10-18, workstation, pinned digests): `surreal start --help` (credential and bind options carry `[env: SURREAL_*]`); running the pinned SurrealDB digest with env-only credentials (credential init in the log, correct login authenticates, wrong password refused, `/surreal is-ready` → `OK` exit 0, named volume owned by the mapped uid); the v1-compatible Quadlet generator dry-run of all three unit files (two `--env-file` in order, two `--network` in order, named volume + host bind, both health probes, `--pull never`)
|
||||||
|
- Host evidence (Dankclaw, 2026-10-18, read-only): `ss -ltn` (`127.0.0.1:5055` held by seerr, `0.0.0.0:7771` a host listener, nothing on 7772/8502/5056/8000), `podman ps -a` (no open-notebook container, `dankclaw_default` present), subnet registry from `dankclaw/open-webui/quadlet/open-webui.network`
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
type: research
|
type: research
|
||||||
title: Local TTS and Voice Cloning
|
title: Local TTS and Voice Cloning
|
||||||
description: How to create a new local TTS voice from sample files in oh-my-pi, the 2026 open-weight voice-cloning landscape, the full local AI audio stack, and a recommended homelab deployment.
|
description: How to create a new local TTS voice from sample files in oh-my-pi, the 2026 open-weight voice-cloning landscape, the measured CPU engine A/B behind frank-tts and the arm A decision, the full local AI audio stack, and a recommended homelab deployment.
|
||||||
tags:
|
tags:
|
||||||
- research
|
- research
|
||||||
- tts
|
- tts
|
||||||
@@ -10,7 +10,7 @@ tags:
|
|||||||
- stt
|
- stt
|
||||||
- vllm-omni
|
- vllm-omni
|
||||||
- kokoro
|
- kokoro
|
||||||
timestamp: 2026-10-18T00:00:00Z
|
timestamp: 2026-10-18T16:05:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Local TTS and Voice Cloning
|
# Local TTS and Voice Cloning
|
||||||
@@ -47,13 +47,16 @@ greenfield: every fleet endpoint declares `input: [text, image]`
|
|||||||
local server needs a speech-role entry, not a patch.
|
local server needs a speech-role entry, not a patch.
|
||||||
- **Fleet plan (2026-10-18): CPU-only on Dankclaw, not the 3090.** Gitea issue
|
- **Fleet plan (2026-10-18): CPU-only on Dankclaw, not the 3090.** Gitea issue
|
||||||
#2 rules out every GPU option (the 3090 has carried frank-zealot since
|
#2 rules out every GPU option (the 3090 has carried frank-zealot since
|
||||||
2026-10-17; both R9700s are frank-archon's), so the shape is a VoxCPM2 GGUF
|
2026-10-17; both R9700s are frank-archon's). The shape is a TTS engine on
|
||||||
engine on **8880** behind a voice-registry shim on **8890**
|
**8880** behind **frank-tts**, the fleet's speech endpoint on **7772**
|
||||||
(`scripts/voiceshim.env`, `dankclaw/voiceservice/`) that speaks OpenAI
|
(`scripts/frank-tts.env`, `dankclaw/voiceservice/`): one OpenAI
|
||||||
`/v1/audio/speech` and resolves voices by name. Nothing is deployed yet — on
|
`/v1/audio/speech` façade that resolves a voice *by name* and never shows a
|
||||||
2026-10-18 `voiceshim` has no unit installed and nothing answers on 8880 or
|
path. Nothing is deployed — no `frank-tts` unit and no container exist on the
|
||||||
8890. STT (Parakeet TDT 1.1B, CC-BY-4.0) stays on CPU. **No RTF or quality
|
host and nothing listens on 7772 or 8880 (checked 2026-10-18). STT (Parakeet
|
||||||
number has been measured for this shape**, so this page states none.
|
TDT 1.1B, CC-BY-4.0) stays on CPU. **The engine is chosen: arm A**
|
||||||
|
`qwentts.cpp` **resident `tts-server`, 1.7 B talker** — measured in
|
||||||
|
*Measured CPU engine A/B* below and picked on the live-output criterion in
|
||||||
|
[Decision: arm A for live output](#decision-arm-a-for-live-output-2026-10-18).
|
||||||
|
|
||||||
## Can Kokoro learn a new voice?
|
## Can Kokoro learn a new voice?
|
||||||
|
|
||||||
@@ -133,11 +136,11 @@ accepts only its built-in voice list, so a custom pack needs a patch.
|
|||||||
|
|
||||||
Paths for a Frank-cloned voice (2026-10-18):
|
Paths for a Frank-cloned voice (2026-10-18):
|
||||||
|
|
||||||
1. **Local server + speech role, no patch.** Register the local engine (or the
|
1. **Local server + speech role, no patch.** Register frank-tts (port 7772) as
|
||||||
8890 voice shim) as a provider model on the `openai-speech` API and point
|
a provider model on the `openai-speech` API and point `modelRoles.speech` at
|
||||||
`modelRoles.speech` at it; the shim turns a voice *name* into a reference
|
it; the façade turns a voice *name* into that pack's reference clip and
|
||||||
clip and forwards it as base64 `reference_audio`
|
forwards it as base64 `reference_audio` (`scripts/frank-tts.env`,
|
||||||
(`scripts/voiceshim.env:19-35`). Not deployed as of 2026-10-18.
|
`TTS_BACKEND=omni-http`). Not deployed as of 2026-10-18.
|
||||||
2. **xAI console custom voice — works today, zero code.** Record 2–3 min clean
|
2. **xAI console custom voice — works today, zero code.** Record 2–3 min clean
|
||||||
speech (≤120 s, 24 kHz 16-bit mono WAV) → console → `voice_id` → speech
|
speech (≤120 s, 24 kHz 16-bit mono WAV) → console → `voice_id` → speech
|
||||||
tool. Free (≤30 voices), cloud, US-only.
|
tool. Free (≤30 voices), cloud, US-only.
|
||||||
@@ -206,29 +209,161 @@ The CPU shape does not need a Python server at all:
|
|||||||
`VoxCPM2-Acoustic-F16.gguf`, Apache-2.0 card) or from the fork's own
|
`VoxCPM2-Acoustic-F16.gguf`, Apache-2.0 card) or from the fork's own
|
||||||
`tools/omni/voxcpm2/convert_voxcpm2_to_gguf.py`.
|
`tools/omni/voxcpm2/convert_voxcpm2_to_gguf.py`.
|
||||||
|
|
||||||
Nothing has been measured on this fleet for any of these engines — no RTF, no
|
Arm B (`llama-tts`) and arm A (`qwentts.cpp`) have both been measured on this
|
||||||
latency, no MOS. That absence is the finding, not an omission.
|
box — RTF, first-audio latency and RSS are in the next section. The other
|
||||||
|
servers in the table above (vLLM-Omni, Kokoro-FastAPI, CosyVoice, F5-TTS,
|
||||||
|
GPT-SoVITS) remain unmeasured here; that absence is still the finding.
|
||||||
|
|
||||||
|
## Measured CPU engine A/B (2026-10-18)
|
||||||
|
|
||||||
|
Both candidate upstreams for frank-tts were measured on Dankclaw's CPU — 8
|
||||||
|
threads, no GPU touched, three runs per arm, seed 42, identical text, against
|
||||||
|
the fleet's own audition voice packs.
|
||||||
|
|
||||||
|
- **Arm A — [`ServeurpersoCom/qwentts.cpp`](https://github.com/ServeurpersoCom/qwentts.cpp)**
|
||||||
|
at commit `5916bf7c79dbb4e66fc059cdc507a79a23a28bb4` (2026-10-16), weights
|
||||||
|
from [`Serveurperso/Qwen3-TTS-GGUF`](https://huggingface.co/Serveurperso/Qwen3-TTS-GGUF):
|
||||||
|
`qwen-talker-1.7b-base-Q4_K_M.gguf` (1,219,245,248 B),
|
||||||
|
`qwen-talker-0.6b-base-Q4_K_M.gguf` (628,905,056 B),
|
||||||
|
`qwen-tokenizer-12hz-Q8_0.gguf` (291,150,624 B) — byte counts verified
|
||||||
|
against the Hub API. Builds with zero compiler warnings; `GGML_BLAS=OFF` is
|
||||||
|
forced on this host (only `libopenblas.so.0` exists, no dev headers, so the
|
||||||
|
vendor `buildcpu.sh` cannot configure).
|
||||||
|
- **Arm B — mainline `llama-tts`** with `--tts-speaker-file`, i.e. the
|
||||||
|
`llama-tts-cli` backend `scripts/frank-tts.env` already implements.
|
||||||
|
|
||||||
|
RTF is **seconds of compute per second of audio** (lower is better; below 1 is
|
||||||
|
faster than real time).
|
||||||
|
|
||||||
|
| Arm | RTF | Run spread | Peak RSS |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A · 0.6 B talker, CLI | 0.44 | ±0.002 | 3.4 GiB |
|
||||||
|
| A · 1.7 B, CLI, uncloned | 0.57–0.58 | ±0.006 | 4.7 GiB |
|
||||||
|
| A · 1.7 B, CLI, embedding-only clone | 0.63 | ±0.02 | n/m |
|
||||||
|
| A · 1.7 B, CLI, ICL clone | 0.92 | ±0.01 | n/m |
|
||||||
|
| A · 1.7 B, resident `tts-server`, warm | 0.55–0.57 | ±0.01 | 5.7 GiB |
|
||||||
|
| A · same, ICL voice warm / **cold (first request)** | 0.67 / **1.65** | one-shot | 5.7 GiB |
|
||||||
|
| B · mainline `llama-tts` | 0.93–0.99 | ±0.25 | n/m |
|
||||||
|
|
||||||
|
**Read RTF only within an arm.** Conditioning changes clip length — the same
|
||||||
|
seed yields 18.00 s of audio embedding-only and 15.12 s under ICL — so the
|
||||||
|
denominator is the arm's own audio duration.
|
||||||
|
|
||||||
|
Arm A's resident server is ~1.7× faster than arm B and about 40× tighter in
|
||||||
|
run-to-run spread, and it already serves the HTTP shape frank-tts's façade
|
||||||
|
speaks: `/health`, `/v1/models`, `/v1/audio/voices` (GET/POST/DELETE),
|
||||||
|
`/v1/audio/speech` (`wav`/`pcm`), chunked streaming with the first audio byte
|
||||||
|
at 86–451 ms and a server-side time-to-first-audio of 94–112 ms. Arm B has no
|
||||||
|
HTTP path at all — that is what the façade's CLI backend exists for.
|
||||||
|
|
||||||
|
Four laws came out of the trial, each worth a line of configuration:
|
||||||
|
|
||||||
|
- **Boot-warm every voice.** The first request for a registered voice decodes
|
||||||
|
the whole reference to snapshot codec state: 6.2–8.0 s, first audio byte at
|
||||||
|
5,530 ms. Later requests restore that state in 0.5 ms. An engine unit must
|
||||||
|
run one throwaway synthesis per voice at start, or the first request of the
|
||||||
|
day pays five seconds.
|
||||||
|
- **Speaker artifacts are reusable and exact.** `qwen-codec` turns a reference
|
||||||
|
WAV into `.spk` (8,192 B x-vector) + `.rvq` (5,456–6,864 B, 16 codebooks × T,
|
||||||
|
11-bit packed) in 1.8–2.1 s at 1.3–1.5 GiB peak; replaying them yields a
|
||||||
|
**byte-identical** WAV and saves 1.4 s per request. That pair is the
|
||||||
|
engine-artifact term the voice-pack law in `scripts/frank-tts.env` reserves a
|
||||||
|
slot for — arm A pins it as `.spk` + `.rvq` beside `reference.wav`.
|
||||||
|
- **Streaming is not bit-identical to buffered**, whatever the upstream README
|
||||||
|
claims: same text, seed and voice, 92.2 % of samples differ (peak delta
|
||||||
|
1139/32768, −41.6 dB RMS — inaudible). A fixed seed does not pin output
|
||||||
|
across `response_format`, so never mix the two paths in a cache or a golden
|
||||||
|
fixture.
|
||||||
|
- **A voice cannot be registered from an embedding alone**: `POST
|
||||||
|
/v1/audio/voices` accepts `wav_b64` or **both** `spk_b64` and `rvq_b64`
|
||||||
|
(`src/tts-server.h:480`), so an embedding-only voice has to be sent as the
|
||||||
|
WAV, or the lone-`spk` path has to be upstreamed.
|
||||||
|
|
||||||
|
**Likeness is not measurable at this resolution.** x-vector cosine against each
|
||||||
|
speaker's own reference: ICL 0.9886 / 0.9901, embedding-only 0.9844 / 0.9812,
|
||||||
|
uncloned 0.9228 — but **two different speakers score 0.9497** on the same
|
||||||
|
metric. The whole clone effect lives in a 0.04-wide band and the
|
||||||
|
ICL-over-embedding gap is inside noise, so the four audition clips
|
||||||
|
(`/home/blank/voice-audition/qwentts-*.mp3`, each against its `armB-*.mp3`
|
||||||
|
mainline partner, guide in `LISTEN-engine.md`) are the instrument. **The
|
||||||
|
operator heard them on 2026-10-18 and passed every clone**, which removed the
|
||||||
|
last axis measurement could not reach; the engine was then picked on
|
||||||
|
live-output grounds ([Decision](#decision-arm-a-for-live-output-2026-10-18)).
|
||||||
|
The cosine
|
||||||
|
extractor is still worth keeping: it is engine-neutral (it scores mainline
|
||||||
|
output through the same encoder) and it separates an uncloned voice cleanly.
|
||||||
|
|
||||||
|
Two operational details. The engine takes 8 threads and exposes **no `-t`
|
||||||
|
flag** — `src/backend.h` uses `hardware_concurrency()/2` and prints the choice
|
||||||
|
at load, so pinning a cpuset on the quadlet changes that number implicitly. And
|
||||||
|
ICL needs a reference transcript that did not exist: the fleet has no ASR
|
||||||
|
endpoint and no audio input, so the crops were transcribed with
|
||||||
|
`vosk-small-en-us` inside the work dir, and two of three transcripts came back
|
||||||
|
suspect ("demon souls" for *Dark Souls*) — a reason to curate `ref_text` into
|
||||||
|
the pack manifest instead of transcribing on the host.
|
||||||
|
|
||||||
|
## Decision: arm A for live output (2026-10-18)
|
||||||
|
|
||||||
|
Every audition pair passed, so likeness stopped being the tiebreak. The
|
||||||
|
question became **which engine is better for live output** — the harness
|
||||||
|
speaking a reply, an alert read aloud, a click-to-speak — and on that criterion
|
||||||
|
RTF only has to stay under 1.0 with margin: what separates the arms is **time to
|
||||||
|
first audio** and the **spread**, because an arm whose RTF can cross 1.0 stalls
|
||||||
|
a live stream mid-sentence.
|
||||||
|
|
||||||
|
| | arm A · `qwentts.cpp` `tts-server` | arm B · mainline `llama-tts` |
|
||||||
|
|---|---|---|
|
||||||
|
| first audio byte | 86–451 ms | model load, per request |
|
||||||
|
| warm RTF | 0.55–0.67 | 0.93–0.99 |
|
||||||
|
| run spread | ±0.01 | ±0.25 |
|
||||||
|
| HTTP | serves `/v1/audio/speech` | none — façade CLI only |
|
||||||
|
| RAM | 5.7 GiB resident | per-request, freed after |
|
||||||
|
|
||||||
|
**Chosen: arm A, 1.7 B talker, resident `tts-server` in a `frank-tts-engine`
|
||||||
|
container, with `TTS_BACKEND=llama-tts-cli` kept in the repo as the rollback.**
|
||||||
|
Three consequences are configuration, not opinion: the unit **boot-warms every
|
||||||
|
voice** (5.5 s cold first byte otherwise), it **does not pin a cpuset** casually
|
||||||
|
(threads are `hardware_concurrency()/2`, no flag), and every pack registers with
|
||||||
|
the **WAV** because the engine rejects a lone embedding.
|
||||||
|
|
||||||
|
Two follow-ups stay open on purpose, and neither blocks the deploy:
|
||||||
|
|
||||||
|
- **Talker size.** The 0.6 B arm measured 0.44 RTF at 3.4 GiB and has never
|
||||||
|
been auditioned — every clip above is 1.7 B. It is the lever for a CPU
|
||||||
|
co-tenancy problem, not a preemptive saving.
|
||||||
|
- **ICL vs embedding-only.** Both passed the ear test. Embedding-only is the
|
||||||
|
shipped default: 0.57–0.63 vs 0.67 warm, and it needs no `ref_text`, which
|
||||||
|
this fleet cannot produce honestly (the ASR crops came back suspect). Packs
|
||||||
|
still carry `.spk` + `.rvq`, so ICL remains a per-voice opt-in for batch work
|
||||||
|
like the podcast render, where total time matters more than first byte.
|
||||||
|
|
||||||
## Recommended deployment (Dankclaw)
|
## Recommended deployment (Dankclaw)
|
||||||
|
|
||||||
As decided in Gitea issue #2, this stays **CPU-only on Dankclaw**: the RTX 3090
|
As decided in Gitea issue #2, this stays **CPU-only on Dankclaw**: the RTX 3090
|
||||||
has carried frank-zealot since 2026-10-17 and both R9700s are frank-archon's,
|
has carried frank-zealot since 2026-10-17 and both R9700s are frank-archon's,
|
||||||
so no card may be asked to co-host TTS. The shape is one engine plus a thin
|
so no card may be asked to co-host TTS. The shape is one engine plus one
|
||||||
voice shim, and **nothing here is deployed as of 2026-10-18** — `systemctl
|
façade, and **nothing here is deployed as of 2026-10-18**: `frank-tts` has
|
||||||
--user is-enabled voiceshim` reports `not-found` and nothing answers on 8880 or
|
neither a unit nor a container on the host, and nothing listens on 7772 or
|
||||||
8890.
|
8880.
|
||||||
|
|
||||||
| Service | Image / install | Port | CPU | Notes |
|
| Service | Image / install | Port | CPU | Notes |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| TTS engine | `llama-tts-server` (`tc-mb/llama.cpp-omni`) on a VoxCPM2 GGUF; `vllm/vllm-omni` + Qwen3-TTS only if a GPU is ever deliberately freed | 8880 | engine decision still open | OpenAI `/v1/audio/speech`; `n_gpu_layers` is there for a GPU boot |
|
| TTS engine | **arm A `qwentts.cpp` `tts-server`** on the Qwen3-TTS GGUFs (decided 2026-10-18, above); arm B mainline `llama-tts` stays in the repo as the `llama-tts-cli` rollback; `vllm/vllm-omni` + Qwen3-TTS only if a GPU is ever deliberately freed | 8880 (container-internal) | open, evidence above | arm A serves OpenAI `/v1/audio/speech` itself and boot-warms each voice; arm B has no HTTP path and runs through the façade's CLI backend |
|
||||||
| Voice shim | `dankclaw/voiceservice/` (stdlib container) + `scripts/voiceshim.env` + `dankclaw/voiceservice/quadlet/voiceshim.container` | 8890 | yes | voice *name* → `reference.wav`, `voice`/`voice_id` on the wire, mp3 re-encode; backends `omni-http` (forwards base64 `reference_audio`) and `llama-tts-cli` (`--tts-speaker-file`, `nice 19`) |
|
| frank-tts | `dankclaw/voiceservice/` (stdlib container) + `scripts/frank-tts.env` + its quadlet | 7772 | yes | voice *name* → `/voices/<name>/reference.wav`, `voice`/`voice_id` on the wire, mp3 re-encode; backends `omni-http` (forwards base64 `reference_audio`) and `llama-tts-cli` (`--tts-speaker-file`, `nice 19`) |
|
||||||
| STT (English) | Parakeet TDT 1.1B behind a FastAPI wrapper, or whisper.cpp server | 8882 | yes | CC-BY-4.0; LibriSpeech-clean WER 1.39 (model card) |
|
| STT (English) | Parakeet TDT 1.1B behind a FastAPI wrapper, or whisper.cpp server | 8882 | yes | CC-BY-4.0; LibriSpeech-clean WER 1.39 (model card) |
|
||||||
| STT (multilingual, opt.) | faster-whisper `large-v3` int8 | 8883 | yes | MIT |
|
| STT (multilingual, opt.) | faster-whisper `large-v3` int8 | 8883 | yes | MIT |
|
||||||
| VAD | Silero (embedded) | — | yes | MIT; model file 2 327 524 B |
|
| VAD | Silero (embedded) | — | yes | MIT; model file 2 327 524 B |
|
||||||
|
|
||||||
8880/8890 are what `scripts/voiceshim.env:35,49` carry (`TTS_UPSTREAM=
|
7772 is frank-tts's host port — the next slot in the fleet's model-endpoint
|
||||||
http://host.containers.internal:8880`, `SHIM_PORT=8890`); 8882-8883 for STT
|
family (7770 frank-dragoon, 7771 frank-zealot). The engine behind it keeps 8880,
|
||||||
keep clear of the fleet's LLM ports 7770/7771.
|
but **not as a host-loopback publish**: netavark DNATs published ports only, and
|
||||||
|
a bridge container cannot reach a `127.0.0.1` bind on the host — that is the
|
||||||
|
lesson that 502'd hermes (`AGENTS.md`). `host.containers.internal`
|
||||||
|
(169.254.1.2) is the gateway, so it reaches a host service only when that
|
||||||
|
service binds a routable address; `scripts/frank-tts.env` names it as the
|
||||||
|
engine's address, which holds for a `0.0.0.0` bind and not for a loopback one.
|
||||||
|
The deployed pair therefore shares one quadlet network and the façade addresses
|
||||||
|
the engine by container name.
|
||||||
|
|
||||||
## Voice-creation workflow (the "from sample files" answer)
|
## Voice-creation workflow (the "from sample files" answer)
|
||||||
|
|
||||||
@@ -284,6 +419,10 @@ keep clear of the fleet's LLM ports 7770/7771.
|
|||||||
https://huggingface.co/openbmb/VoxCPM2
|
https://huggingface.co/openbmb/VoxCPM2
|
||||||
- llama.cpp-omni TTS server: https://github.com/tc-mb/llama.cpp-omni ·
|
- llama.cpp-omni TTS server: https://github.com/tc-mb/llama.cpp-omni ·
|
||||||
https://raw.githubusercontent.com/tc-mb/llama.cpp-omni/master/tools/omni/voxcpm2/README.md
|
https://raw.githubusercontent.com/tc-mb/llama.cpp-omni/master/tools/omni/voxcpm2/README.md
|
||||||
|
- qwentts.cpp (arm A): https://github.com/ServeurpersoCom/qwentts.cpp at commit
|
||||||
|
`5916bf7c79dbb4e66fc059cdc507a79a23a28bb4` · weights
|
||||||
|
https://huggingface.co/Serveurperso/Qwen3-TTS-GGUF (byte counts against the
|
||||||
|
Hub API, 2026-10-18)
|
||||||
- mainline `llama-tts`:
|
- mainline `llama-tts`:
|
||||||
https://raw.githubusercontent.com/ggml-org/llama.cpp/master/tools/tts/README.md ·
|
https://raw.githubusercontent.com/ggml-org/llama.cpp/master/tools/tts/README.md ·
|
||||||
Pocket-TTS weights: https://huggingface.co/kyutai/pocket-tts (CC-BY-4.0)
|
Pocket-TTS weights: https://huggingface.co/kyutai/pocket-tts (CC-BY-4.0)
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ macOS mini) as quadlets inside its podman machine — not on Dankserver.
|
|||||||
- [[Postgres]] — Gitea database backend
|
- [[Postgres]] — Gitea database backend
|
||||||
- [[Seerr]] — unified *arr request manager (guard-chained; fronts FrankBook-Pro's stack)
|
- [[Seerr]] — unified *arr request manager (guard-chained; fronts FrankBook-Pro's stack)
|
||||||
- [[Marreta]] — content-cleaning proxy (guard-chained; shares SearXNG's Gluetun egress)
|
- [[Marreta]] — content-cleaning proxy (guard-chained; shares SearXNG's Gluetun egress)
|
||||||
|
- [[Open Notebook]] — podcast generator (scripts by frank-zealot, voices by frank-tts); repo-owned config, not deployed
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
|
|||||||
@@ -2311,3 +2311,133 @@ every restart since the cutover.
|
|||||||
Kokoro-enum-only — so "the agent talks in my voice" needs that provider row or
|
Kokoro-enum-only — so "the agent talks in my voice" needs that provider row or
|
||||||
a `pi.registerProvider` extension, decided in phase 2.
|
a `pi.registerProvider` extension, decided in phase 2.
|
||||||
|
|
||||||
|
## 2026-10-18 — TTS engine picked: arm A `qwentts.cpp`, on the live-output criterion
|
||||||
|
|
||||||
|
- Frank listened to the four audition pairs (`qwentts-*.mp3` against their
|
||||||
|
`armB-*.mp3` mainline partners) and **passed every clone**. That deleted the
|
||||||
|
only axis the A/B could not measure, and the question was reframed: **which
|
||||||
|
engine is better for live output**. On that criterion RTF only has to stay
|
||||||
|
under 1.0 with margin — the discriminators are time-to-first-audio and
|
||||||
|
run-to-run spread, because an arm whose RTF can cross 1.0 stalls a live
|
||||||
|
stream mid-sentence.
|
||||||
|
- **Chosen: arm A `qwentts.cpp` @ `5916bf7`, the resident `tts-server`, 1.7 B
|
||||||
|
talker Q4_K_M + Q8_0 tokenizer, in a `frank-tts-engine` container on 8880.**
|
||||||
|
The `TTS_BACKEND=llama-tts-cli` path in `scripts/frank-tts.env` stays in the
|
||||||
|
repo as the rollback. The numbers that carried it: first audio byte 86–451 ms
|
||||||
|
against a per-request model load, warm RTF 0.55–0.67 against 0.93–0.99, spread
|
||||||
|
±0.01 against ±0.25, and a native `/v1/audio/speech` against no HTTP path at
|
||||||
|
all. Three consequences are configuration, not preference: boot-warm every
|
||||||
|
voice, do not pin a cpuset (threads are `hardware_concurrency()/2`), register
|
||||||
|
every pack with its WAV (a lone embedding is rejected).
|
||||||
|
- Two follow-ups deliberately **not** decided: the **0.6 B talker** (0.44 RTF,
|
||||||
|
3.4 GiB, never auditioned — every clip is 1.7 B) and **ICL vs
|
||||||
|
embedding-only** conditioning. Embedding-only is the shipped default because
|
||||||
|
it is cheaper and needs no `ref_text`, which this fleet cannot transcribe
|
||||||
|
honestly; `.spk` + `.rvq` still ride in every pack, so ICL remains a per-voice
|
||||||
|
opt-in for batch work where total time beats first byte.
|
||||||
|
- **Addressing law corrected** in
|
||||||
|
[[Local TTS and Voice Cloning]]: the pair may not meet at a host-loopback
|
||||||
|
publish. netavark DNATs published ports only and a bridge container cannot
|
||||||
|
reach a host `127.0.0.1` bind — the lesson that 502'd hermes — so
|
||||||
|
`host.containers.internal:8880`, which `scripts/frank-tts.env` documents, is
|
||||||
|
correct only for a routable bind. The deployed pair shares one quadlet network
|
||||||
|
and the façade addresses the engine by container name.
|
||||||
|
- **PR #12** (`feat/open-notebook-podcast` @ `114dd7a`, 8 commits, 403 tests) is
|
||||||
|
**merged** as `1eb8b21` (Frank's OK, given in session): open-notebook +
|
||||||
|
SurrealDB quadlets on the private bridge `10.89.4.0/24`, the API published on
|
||||||
|
**5056** because seerr holds `127.0.0.1:5055`, and `ESPERANTO_TTS_TIMEOUT`
|
||||||
|
moved 600 → **660** to sit one round-trip outside frank-tts's own
|
||||||
|
`SHIM_TIMEOUT`. Merged is not runnable: nothing listens on 7772 until the
|
||||||
|
voice stack lands, and the episode destination (`/home/dank/media/podcasts`)
|
||||||
|
stays interim pending Gitea #7.
|
||||||
|
- Still in flight, not in this branch: `feat/frank-tts-engine` — the arm A image
|
||||||
|
build, the quadlet pair on one network, the boot-warm step, deployer /
|
||||||
|
planner / `test-changed.sh` routing, homepage and docs. This branch is also
|
||||||
|
where the two-branch overlap got resolved: merging `main` here conflicted on
|
||||||
|
`log.md` alone (both branches appended an entry) and took `index.md`,
|
||||||
|
`deploy-remote.sh`, `deploy-plan.py` and `test-changed.sh` clean.
|
||||||
|
|
||||||
|
## 2026-10-18 — open-notebook: the podcast generator's config lands (nothing deployed)
|
||||||
|
|
||||||
|
- Goal: **open-notebook** as the fleet's podcast generator on Dankclaw — notes
|
||||||
|
and sources in, a multi-speaker episode out, script written by
|
||||||
|
**frank-zealot**, voices rendered by **frank-tts**, no cloud provider
|
||||||
|
anywhere in the path. Repo-side only: no host touched, no container pulled or
|
||||||
|
started. Runbook: [[Open Notebook]].
|
||||||
|
- **Two containers, not one.** `lfnovo/open_notebook:v1-latest` plus
|
||||||
|
`surrealdb/surrealdb:v2`, both **digest-pinned** with `Pull=never` and a
|
||||||
|
documented one-time `podman pull` — frank-dragoon's pinning style, because
|
||||||
|
`:latest` moving underneath a rootless service is how you get an unbootable
|
||||||
|
pod after an unrelated `systemctl-daemon-reload`. The registry round-trip is
|
||||||
|
what proved `v2` is SurrealDB **2.7.0** and that the app image is Debian
|
||||||
|
trixie with **curl and ffmpeg already inside** — which is how both health
|
||||||
|
probes can be argument-only: `curl -fsS …/health` (route is upstream's,
|
||||||
|
`api/main.py:239`, and it sits on the auth exclude list) and
|
||||||
|
`/surreal is-ready`, which I ran against the digest here: green in 2 s, and
|
||||||
|
it refuses to pass without `SURREAL_PASS` in the environment — a credential
|
||||||
|
that must never ride the command line, so `Exec=` carries flags only.
|
||||||
|
- **Host 5055 was already spoken for.** seerr has held `127.0.0.1:5055` since
|
||||||
|
2026-10-14 (live `ss -ltn` agrees today), so the API publishes
|
||||||
|
`0.0.0.0:5056:5055`. open-notebook's frontend derives its API URL from the
|
||||||
|
window origin when `API_URL` is unset — the upstream env reference spells the
|
||||||
|
reverse-proxy symptom — so a silent remap would hang the UI on a port nobody
|
||||||
|
listens on, and `API_URL=http://192.168.0.143:5056` is set accordingly. 8502
|
||||||
|
publishes on `0.0.0.0` because this service carries its own password (the
|
||||||
|
episode-audio route is unauthenticated), unlike the loopback-and-proxied
|
||||||
|
observability stack; **8000 is published nowhere**, where upstream exposes it
|
||||||
|
on loopback with root credentials, and the seam forbids it.
|
||||||
|
- **The CPU guard, pre-armed.** `frank-tts` spawns one ~8 GiB, 8-thread engine
|
||||||
|
per request on a box that also serves two LLMs, while open-notebook defaults
|
||||||
|
to `TTS_BATCH_SIZE=5` × `OPEN_NOTEBOOK_WORKER_MAX_TASKS=5` — a five-way
|
||||||
|
self-DoS. Both are pinned to `1` in `scripts/open-notebook.env`, and the
|
||||||
|
worker knob is only real because the image's own supervisord expands
|
||||||
|
`${OPEN_NOTEBOOK_WORKER_MAX_TASKS:-5}` (`supervisord.conf:25`). The TTS clock
|
||||||
|
goes to **660 — one round-trip outside** frank-tts's own `SHIM_TIMEOUT=600`, so
|
||||||
|
a shim give-up reads as the shim's error rather than the worker's; the seam
|
||||||
|
asserts the direction (`>`) against the frank-tts env instead of restating a
|
||||||
|
number. `ESPERANTO_LLM_TIMEOUT` stays at the app's 180 s default: frank-zealot
|
||||||
|
answers an outline or a transcript well inside it, and upstream says to keep it
|
||||||
|
under 600 so a slow call fails with a name on it.
|
||||||
|
- **What a credential needs in order to exist:** `OPEN_NOTEBOOK_ENCRYPTION_KEY`
|
||||||
|
must be set before the UI can save *any* provider credential, and
|
||||||
|
`OPEN_NOTEBOOK_PASSWORD` must stay **absent** to remain auth-off — the app
|
||||||
|
reads `os.environ.get(…) is not None`, so `PASSWORD=` boots a login form
|
||||||
|
asking for the empty string. The repo env file carries both keys empty; the
|
||||||
|
deployer seeds `/home/dank/Containers/open-notebook/env` (600) from the host
|
||||||
|
vault and **refuses to start the pair while either secret is blank**, because
|
||||||
|
a half-configured open-notebook stores no credentials and fails quietly.
|
||||||
|
- **Episode destination is interim, by decision.** The destination is **Gitea
|
||||||
|
issue #7, `human-only`** — agents must not work it. Until Frank settles it,
|
||||||
|
the app keeps its own named volume and `/home/dank/media/podcasts` is
|
||||||
|
bind-mounted over `/app/data/podcasts` (`DATA_FOLDER` is hard-coded `./data`),
|
||||||
|
so finished MP3s are ordinary files in his home directory instead of volume
|
||||||
|
contents needing `podman unshare` — the database volume demonstrably lands on
|
||||||
|
a mapped subuid, measured with the pinned image. One mount line; one deletion
|
||||||
|
undoes it.
|
||||||
|
- **Two UI buttons will fail, and that is cosmetic.** Test Connection does
|
||||||
|
`GET <base>/models` and the TTS probe synthesises voice `alloy`; frank-tts
|
||||||
|
serves `/health` and `/v1/audio/speech` only. Model rows are created *by
|
||||||
|
name* and the voice field is free text, so `narrator1/2/3` is typed in and
|
||||||
|
never discovered. Documented, not worked around.
|
||||||
|
- **Blocked on frank-tts, stated plainly:** phase 1 landed without deployer or
|
||||||
|
homepage registration and nothing listens on 7772 today, so a generation run
|
||||||
|
writes its script and then fails on the first voice. That is upstream's
|
||||||
|
creation-podcasts warning in fleet clothing.
|
||||||
|
- Suite green: **403/403** (`./tests/run.sh`, wall 67 s) — 19 tests in the new
|
||||||
|
`tests/open_notebook.bats`, structure only: pins, boot persistence, the
|
||||||
|
unpublishable database port, the 5055→5056 remap plus the `API_URL` that has
|
||||||
|
to match it, the env-file read order, the guard knobs, the health gate naming
|
||||||
|
`/health`, planner routing, the homepage card hidden while undeployed.
|
||||||
|
- Three deployer seams cost time and are now law-shaped: a service must be
|
||||||
|
registered in **five** places — `SERVICE_ORDER`, `DANKCLAW_SERVICES`, the
|
||||||
|
`deploy-remote.sh` dispatch case, its pre-deploy requirement, and
|
||||||
|
`deploy.sh`'s dispatch regex, where an omission silently plans an empty set
|
||||||
|
for `--only`; a `dankclaw/<svc>/*` case must precede the `dankclaw/*.container`
|
||||||
|
glob because that `*` crosses `/`; and the changed-path routing lives *inside*
|
||||||
|
the per-path loop, after the `scripts/*.env)` case, not at the top of the file.
|
||||||
|
- Deliberately NOT done: the deploy (pulling 1.27 GiB to Dankclaw was not
|
||||||
|
asked for), any Caddy vhost or public exposure, audiobook/EPUB (issue #5), any
|
||||||
|
`frank-tts` code change, and the harbor templates on Dankserver —
|
||||||
|
`~/.harbor/compose.opennotebook.yml` pins the deprecated `-single` image,
|
||||||
|
carries no encryption key and injects the whole harbor env — left untouched,
|
||||||
|
read-only, as the record of what not to copy.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><polygon points="48,6 86,27 86,69 48,90 10,69 10,27" fill="#101a12" stroke="#7ee787" stroke-width="3"/><text x="48" y="60" font-family="monospace" font-size="30" font-weight="bold" fill="#7ee787" text-anchor="middle">ON</text></svg>
|
||||||
|
After Width: | Height: | Size: 293 B |
@@ -28,6 +28,19 @@
|
|||||||
"art_bg": "#f5f5f5",
|
"art_bg": "#f5f5f5",
|
||||||
"warn": "Chats stay on Frank's server, but OpenWebUI's web search rides the same SearXNG path as the search app: obfuscated, not anonymous. The upstream search API keys are tied to Frank’s identity."
|
"warn": "Chats stay on Frank's server, but OpenWebUI's web search rides the same SearXNG path as the search app: obfuscated, not anonymous. The upstream search API keys are tied to Frank’s identity."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "open-notebook",
|
||||||
|
"category": "user",
|
||||||
|
"section": "AI",
|
||||||
|
"description": "Podcast generator — notes in, spoken episode out: script written by frank-zealot, voices rendered by frank-tts. No public route; LAN UI only once it is deployed.",
|
||||||
|
"url": "",
|
||||||
|
"docs": "",
|
||||||
|
"probe": "127.0.0.1:8502",
|
||||||
|
"method": "http",
|
||||||
|
"wip": "down",
|
||||||
|
"showLink": false,
|
||||||
|
"art": "/img/apps/mono-on.svg"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Gitea",
|
"name": "Gitea",
|
||||||
"category": "user",
|
"category": "user",
|
||||||
|
|||||||
Executable
+274
@@ -0,0 +1,274 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# Seam: open-notebook, the fleet's podcast generator on Dankclaw — the Quadlet
|
||||||
|
# pair (app + SurrealDB), the private network between them, the env file both
|
||||||
|
# units read, the deployer routing, and the homepage card. Runbook:
|
||||||
|
# serverAppConfigs-docs/Dankclaw/Open Notebook.md.
|
||||||
|
#
|
||||||
|
# Structure only: what the files declare and what the deployer can see. Nothing
|
||||||
|
# here starts a container, opens a socket, or touches the network — the app's
|
||||||
|
# behaviour inside the image is upstream's contract, cited in the docs page.
|
||||||
|
|
||||||
|
load helpers
|
||||||
|
|
||||||
|
UNIT_DIR="dankclaw/open-notebook/quadlet"
|
||||||
|
APP="$REPO_ROOT/$UNIT_DIR/open-notebook.container"
|
||||||
|
DB="$REPO_ROOT/$UNIT_DIR/open-notebook-surrealdb.container"
|
||||||
|
NET="$REPO_ROOT/$UNIT_DIR/open-notebook.network"
|
||||||
|
ENVF="$REPO_ROOT/scripts/open-notebook.env"
|
||||||
|
PLANNER="$REPO_ROOT/scripts/deploy-plan.py"
|
||||||
|
REMOTE="$REPO_ROOT/scripts/deploy-remote.sh"
|
||||||
|
# The host-side path the units read (they run on Dankclaw, not here), spelled
|
||||||
|
# out once so the ordering test below compares against a real constant.
|
||||||
|
HOST_TUNABLES="EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env"
|
||||||
|
HOST_SECRETS="EnvironmentFile=/home/dank/Containers/open-notebook/env"
|
||||||
|
|
||||||
|
# The pins are part of the contract, so a silent re-resolution to a different
|
||||||
|
# build has to fail here rather than at the next deploy. Both digests were
|
||||||
|
# resolved from the registry on 2026-10-18; the SurrealDB one is 2.7.0.
|
||||||
|
APP_PIN="sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212"
|
||||||
|
DB_PIN="sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723"
|
||||||
|
|
||||||
|
@test "open-notebook ships a network, a database unit and an app unit" {
|
||||||
|
[ -f "$NET" ]
|
||||||
|
[ -f "$DB" ]
|
||||||
|
[ -f "$APP" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "both units are boot-persistent and self-healing" {
|
||||||
|
for unit in "$APP" "$DB"; do
|
||||||
|
grep -qE '^Restart=(always|on-failure)$' "$unit" || {
|
||||||
|
echo "$(basename "$unit") has no Restart="; false; }
|
||||||
|
# "short RestartSec": a podcast service has no reason to wait minutes.
|
||||||
|
restart_sec="$(sed -n 's/^RestartSec=\([0-9]*\)s\?$/\1/p' "$unit")"
|
||||||
|
[ -n "$restart_sec" ] || {
|
||||||
|
echo "$(basename "$unit") has no RestartSec"; false; }
|
||||||
|
[ "$restart_sec" -le 30 ] || {
|
||||||
|
echo "$(basename "$unit") RestartSec=${restart_sec}s is not short"; false; }
|
||||||
|
grep -qE '^\[Install\]' "$unit"
|
||||||
|
grep -qE '^WantedBy=default\.target$' "$unit"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "both images are digest-pinned and never re-resolved at start" {
|
||||||
|
grep -qxF "Image=docker.io/lfnovo/open_notebook:v1-latest@$APP_PIN" "$APP"
|
||||||
|
grep -qxF "Image=docker.io/surrealdb/surrealdb:v2@$DB_PIN" "$DB"
|
||||||
|
grep -qxF 'Pull=never' "$APP"
|
||||||
|
grep -qxF 'Pull=never' "$DB"
|
||||||
|
# Pull=never is only honest if the fetch it depends on is written down where
|
||||||
|
# the operator reads the unit, and wired into the deployer's precondition.
|
||||||
|
grep -qF 'podman pull' "$APP"
|
||||||
|
grep -qF 'podman pull' "$DB"
|
||||||
|
grep -qF 'ensure_pinned_image' "$REMOTE"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the database port is never published" {
|
||||||
|
# 8000 belongs to the private bridge. A published 8000 would put the RPC,
|
||||||
|
# root credentials included, on every interface of the host.
|
||||||
|
! grep -qE '^PublishPort=' "$DB"
|
||||||
|
! grep -qE '^PublishPort=.*:8000(:|$)' "$APP"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the app publishes its UI and moves the API off the host port seerr holds" {
|
||||||
|
grep -qxF 'PublishPort=0.0.0.0:8502:8502' "$APP"
|
||||||
|
# seerr already binds 127.0.0.1:5055 on this host, so the API cannot leave
|
||||||
|
# the container on the host port upstream uses.
|
||||||
|
publish="$(grep -E '^PublishPort=[^:]+:[0-9]+:5055$' "$APP")"
|
||||||
|
[ -n "$publish" ] || { echo "no PublishPort mapping to the API's 5055"; false; }
|
||||||
|
mapping="${publish#PublishPort=}" # e.g. 0.0.0.0:5056:5055
|
||||||
|
host_port="${mapping#*:}"
|
||||||
|
host_port="${host_port%%:*}"
|
||||||
|
[ "$host_port" != "5055" ] || {
|
||||||
|
echo "the API is published on host 5055, which seerr already holds"; false; }
|
||||||
|
# The frontend auto-detects the API as <host it was opened on>:5055, so a
|
||||||
|
# remap is only correct if API_URL names the published port.
|
||||||
|
api_url="$(sed -n 's/^API_URL=//p' "$ENVF")"
|
||||||
|
[ "$api_url" = "http://192.168.0.143:${host_port}" ] || {
|
||||||
|
echo "API_URL ($api_url) does not name the published API port ($host_port)"; false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the pair shares one network and the app finds the database by container name" {
|
||||||
|
grep -qxF 'Network=open-notebook.network' "$DB"
|
||||||
|
grep -qxF 'Network=open-notebook.network' "$APP"
|
||||||
|
[ "$(grep -cE '^Network=' "$DB")" = "1" ]
|
||||||
|
# dankclaw_default is the app's second network and must come FIRST: the
|
||||||
|
# generated --network flags keep file order, so the default route (and the
|
||||||
|
# gateway name proven on that network) rides it.
|
||||||
|
[ "$(grep -cE '^Network=' "$APP")" = "2" ]
|
||||||
|
[ "$(grep -E '^Network=' "$APP" | head -1)" = "Network=dankclaw_default" ]
|
||||||
|
db_name="$(sed -n 's/^ContainerName=//p' "$DB")"
|
||||||
|
[ "$db_name" = "open-notebook-surrealdb" ]
|
||||||
|
grep -qxF "SURREAL_URL=ws://${db_name}:8000/rpc" "$ENVF"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "each unit reads the repo tunables first and the host secrets second" {
|
||||||
|
# podman applies --env-file in argument order and the LAST file wins, so
|
||||||
|
# swapping these two lines would blank every credential the app needs.
|
||||||
|
for unit in "$APP" "$DB"; do
|
||||||
|
files="$(grep -E '^EnvironmentFile=' "$unit")"
|
||||||
|
[ "$(echo "$files" | wc -l)" = "2" ] || {
|
||||||
|
echo "$(basename "$unit") must read exactly two env files"; false; }
|
||||||
|
[ "$(echo "$files" | head -1)" = "$HOST_TUNABLES" ] || {
|
||||||
|
echo "$(basename "$unit") must read the tracked env file first"; false; }
|
||||||
|
[ "$(echo "$files" | tail -1)" = "$HOST_SECRETS" ] || {
|
||||||
|
echo "$(basename "$unit") must read the host secret file last"; false; }
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the interim episode destination is mounted exactly once" {
|
||||||
|
# The bind that answers Gitea issue #7 provisionally: two of them is a
|
||||||
|
# shadowing bug, none is episode audio locked inside a named volume.
|
||||||
|
[ "$(grep -cE '^Volume=/home/dank/media/podcasts:/app/data/podcasts$' "$APP")" = "1" ]
|
||||||
|
# The app's own state stays in a named volume; the database gets its own.
|
||||||
|
grep -qE '^Volume=open-notebook-data:/app/data$' "$APP"
|
||||||
|
grep -qE '^Volume=open-notebook-db:/mydata$' "$DB"
|
||||||
|
# The deployer creates the host directory as the deploy user.
|
||||||
|
grep -qF 'mkdir -p /home/dank/media/podcasts' "$REMOTE"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the env file declares the CPU-shutdown guard and both timeouts" {
|
||||||
|
# Upstream defaults are 5 and 5; frank-tts spawns ~8 GiB / 8 threads per
|
||||||
|
# request on a box that also serves two LLM endpoints.
|
||||||
|
grep -qxF 'TTS_BATCH_SIZE=1' "$ENVF"
|
||||||
|
grep -qxF 'OPEN_NOTEBOOK_WORKER_MAX_TASKS=1' "$ENVF"
|
||||||
|
# Per dialogue line, and strictly beyond frank-tts's own per-request
|
||||||
|
# ceiling: nested budgets go inside-out (the rule scripts/frank-tts.env
|
||||||
|
# states for LLAMA_TIMEOUT), so a shim give-up surfaces as frank-tts's
|
||||||
|
# error instead of the worker's own timeout. The seam reads the ceiling
|
||||||
|
# rather than restating it, so re-measuring frank-tts cannot leave this
|
||||||
|
# file silently behind.
|
||||||
|
tts="$(sed -n 's/^ESPERANTO_TTS_TIMEOUT=//p' "$ENVF")"
|
||||||
|
shim="$(sed -n 's/^SHIM_TIMEOUT=//p' "$REPO_ROOT/scripts/frank-tts.env")"
|
||||||
|
[ -n "$tts" ] || { echo "no ESPERANTO_TTS_TIMEOUT in the env file"; false; }
|
||||||
|
[ -n "$shim" ] || { echo "no SHIM_TIMEOUT in scripts/frank-tts.env"; false; }
|
||||||
|
[ "$tts" -gt "$shim" ] || {
|
||||||
|
echo "ESPERANTO_TTS_TIMEOUT=$tts is not beyond frank-tts SHIM_TIMEOUT=$shim"; false; }
|
||||||
|
# Upstream: keep the scriptwriting call under the web UI's ~600 s patience,
|
||||||
|
# or the failure arrives as a UI timeout with no name on it.
|
||||||
|
llm="$(sed -n 's/^ESPERANTO_LLM_TIMEOUT=//p' "$ENVF")"
|
||||||
|
[[ "$llm" =~ ^[0-9]+$ ]] && [ "$llm" -lt 600 ] || {
|
||||||
|
echo "ESPERANTO_LLM_TIMEOUT=$llm is not a number under 600"; false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "secrets stay declared-but-empty in the tracked env file" {
|
||||||
|
# scripts/*.env is un-ignored for "paths and numbers, never credentials":
|
||||||
|
# the values belong to the host file the deployer seeds.
|
||||||
|
for key in OPEN_NOTEBOOK_ENCRYPTION_KEY OPEN_NOTEBOOK_PASSWORD SURREAL_PASSWORD SURREAL_PASS; do
|
||||||
|
grep -qxF "${key}=" "$ENVF" || {
|
||||||
|
echo "$key is not declared, empty, in $ENVF"; false; }
|
||||||
|
done
|
||||||
|
has_no_leaked_secret "$ENVF"
|
||||||
|
has_no_leaked_sudo_pw "$ENVF"
|
||||||
|
# And the deployer treats blank as fatal, because blank means "auth off",
|
||||||
|
# not "unconfigured".
|
||||||
|
grep -qF 'seed_open_notebook_env' "$REMOTE"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the database password never rides in a command line" {
|
||||||
|
# `surreal start` reads SURREAL_USER / SURREAL_PASS from the environment;
|
||||||
|
# --pass would put it in /proc/<pid>/cmdline for any ps on the host.
|
||||||
|
grep -qE '^Exec=start' "$DB"
|
||||||
|
! grep -qE '^Exec=.*--(pass|user)' "$DB"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "both health probes are argument-only and name real paths" {
|
||||||
|
for unit in "$APP" "$DB"; do
|
||||||
|
cmd="$(sed -n 's/^HealthCmd=//p' "$unit")"
|
||||||
|
[ -n "$cmd" ] || { echo "$(basename "$unit") has no HealthCmd"; false; }
|
||||||
|
# The quadlet generator splits HealthCmd on spaces, so anything needing
|
||||||
|
# a shell (quotes, pipes, &&) would be silently mis-sent.
|
||||||
|
! printf '%s' "$cmd" | grep -qE '["&;|`]'
|
||||||
|
done
|
||||||
|
# The API's own route, exempt from its password middleware upstream.
|
||||||
|
grep -qxF 'HealthCmd=curl -fsS http://127.0.0.1:5055/health' "$APP"
|
||||||
|
# The distroless database image has no shell and no curl; it ships a probe.
|
||||||
|
grep -qxF 'HealthCmd=/surreal is-ready' "$DB"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the deployer gates open-notebook on the health path that actually answers" {
|
||||||
|
grep -qF 'open-notebook) deploy_open_notebook ;;' "$REMOTE"
|
||||||
|
gate="$(grep -E '^[[:space:]]*wait_for_health open-notebook ' "$REMOTE" | head -1)"
|
||||||
|
[ -n "$gate" ] || { echo "deploy_open_notebook has no wait_for_health gate"; false; }
|
||||||
|
# /health is the route upstream serves; the suite must not bless a guess.
|
||||||
|
printf '%s' "$gate" | grep -qE '/health( |$)'
|
||||||
|
# The gate is a host-loopback URL (the publish is wildcard, so 127.0.0.1
|
||||||
|
# answers) on the PUBLISHED port, never the container's 5055.
|
||||||
|
printf '%s' "$gate" | grep -qE 'http://127\.0\.0\.1:[0-9]+/health'
|
||||||
|
! printf '%s' "$gate" | grep -qE ':5055/'
|
||||||
|
# The database is gated by its container health before the app is started.
|
||||||
|
grep -qE 'wait_container_healthy open-notebook-surrealdb' "$REMOTE"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the deployer refuses to start the pair on blank secrets and never rewrites a seeded file" {
|
||||||
|
# Read the seed function itself: the refusal, and the no-clobber rule that
|
||||||
|
# protects a password an operator set by hand.
|
||||||
|
seed="$(sed -n '/^seed_open_notebook_env()/,/^}/p' "$REMOTE")"
|
||||||
|
[ -n "$seed" ]
|
||||||
|
printf '%s' "$seed" | grep -qF 'return 1'
|
||||||
|
printf '%s' "$seed" | grep -qF 'if [[ ! -e "$dest" ]]'
|
||||||
|
printf '%s' "$seed" | grep -qF 'chmod 600'
|
||||||
|
# One value, two spellings: the app reads SURREAL_PASSWORD, the CLI SURREAL_PASS.
|
||||||
|
printf '%s' "$seed" | grep -qF 'SURREAL_PASS='
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the planner maps every open-notebook path to the open-notebook service" {
|
||||||
|
run python3 "$PLANNER" --target dankclaw --paths \
|
||||||
|
dankclaw/open-notebook/quadlet/open-notebook.container \
|
||||||
|
dankclaw/open-notebook/quadlet/open-notebook-surrealdb.container \
|
||||||
|
dankclaw/open-notebook/quadlet/open-notebook.network \
|
||||||
|
scripts/open-notebook.env
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$output" = "open-notebook" ]
|
||||||
|
# One deployable owns the pair: the unit files never plan separately.
|
||||||
|
run python3 "$PLANNER" --target dankclaw --paths \
|
||||||
|
dankclaw/open-notebook/quadlet/open-notebook-surrealdb.container
|
||||||
|
[ "$output" = "open-notebook" ]
|
||||||
|
# And the service belongs to dankclaw only.
|
||||||
|
run python3 "$PLANNER" --target dankserver --paths scripts/open-notebook.env
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -z "$output" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "changed-path routing selects this seam" {
|
||||||
|
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths \
|
||||||
|
dankclaw/open-notebook/quadlet/open-notebook.container
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
printf '%s\n' "$output" | grep -qx "tests/open_notebook.bats"
|
||||||
|
|
||||||
|
run "$REPO_ROOT/scripts/test-changed.sh" --list --paths scripts/open-notebook.env
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
printf '%s\n' "$output" | grep -qx "tests/open_notebook.bats"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "both deployer entry points know the service" {
|
||||||
|
grep -qF 'dankclaw:open-notebook|' "$REMOTE"
|
||||||
|
grep -qF 'dankclaw:open-notebook|' "$REPO_ROOT/scripts/deploy.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the homepage card exists and stays hidden until the service is deployed" {
|
||||||
|
python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY'
|
||||||
|
import json, os, sys
|
||||||
|
data = json.load(open(sys.argv[1]))
|
||||||
|
cards = [s for s in data if s["name"] == "open-notebook"]
|
||||||
|
assert len(cards) == 1, f"expected exactly one open-notebook card, got {len(cards)}"
|
||||||
|
card = cards[0]
|
||||||
|
assert card["category"] == "user" and card["section"] == "AI", card
|
||||||
|
assert card["probe"] == "127.0.0.1:8502", card["probe"]
|
||||||
|
assert card["method"] == "http"
|
||||||
|
# Not deployed yet: the card is reported wip-down and carries no link, so the
|
||||||
|
# page shows no dead button (the Seerr precedent).
|
||||||
|
assert card.get("wip") == "down", card.get("wip")
|
||||||
|
assert card.get("showLink") is False, card.get("showLink")
|
||||||
|
assert not card.get("url"), "a hidden card must not carry a url"
|
||||||
|
art = card["art"].lstrip("/")
|
||||||
|
assert os.path.exists(os.path.join(os.path.dirname(sys.argv[1]), art)), art
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the runbook page exists and names the interim mount's owning issue" {
|
||||||
|
page="$REPO_ROOT/serverAppConfigs-docs/Dankclaw/Open Notebook.md"
|
||||||
|
[ -f "$page" ]
|
||||||
|
grep -qF 'issue #7' "$page"
|
||||||
|
grep -qF 'human-only' "$page"
|
||||||
|
# The interim decision is one mount line; the page has to say how to undo it.
|
||||||
|
grep -qF '## Rollback' "$page"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user