Castopod in the pasture

Castopod 1.15.5 (peers/castopod.sh): the official image on the shared MySQL, its podcast @pod made and published through
its admin pages, episodes through its REST API, its fediverse queue sent by spark. scenarios/castopod.sh, 15 checks: the
follow, an episode reaching alice with its sound, her like, boost and comment landing there, the unfollow, statistics
(from NodeInfo2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 11:12:06 +02:00
1 parent 69ab8e50eb
commit 0310bbe0f9
6 files changed
+182

No files matched your search

+5
View File
@@ -607,6 +607,11 @@ tools/pasture/run.sh down # removes e
announced by the other), that alice's public post goes to the relays (and through both to Mastodon: Activity-Relay's announced by the other), that alice's public post goes to the relays (and through both to Mastodon: Activity-Relay's
forward on its FEP-8b32 proof, after the scenario makes Mastodon read alice's keys anew) and nothing less public, and forward on its FEP-8b32 proof, after the scenario makes Mastodon read alice's keys anew) and nothing less public, and
has Mastodon leave again, so the town sees no relayed posts. 16 checks. has Mastodon leave again, so the town sees no relayed posts. 16 checks.
- **Castopod (1.15.5, `peers/castopod.sh`):** the official image on the shared MySQL (database `castopod`, file cache,
`CP_DISABLE_HTTPS`), its database and superadmin (admin@castopod.test) from `spark`, its REST API switched on in the
`.env` the image rewrites at each start, `spark fediverse:broadcast` looping in the container. The podcast `@pod` is
made and published through its admin pages (`cp_web`, CSRF on each form). `scenarios/castopod.sh`, 15 checks; the
episode's sound is made with ffmpeg on the workstation.
- **Forte (26.9.10, `peers/forte.sh`):** built from its tag by `images/forte` (composer on Hubzilla's PHP image, an nginx - **Forte (26.9.10, `peers/forte.sh`):** built from its tag by `images/forte` (composer on Hubzilla's PHP image, an nginx
routing through `index.php?req=`), on the shared MySQL (database `forte`), its `.htconfig.php` written to routing through `index.php?req=`), on the shared MySQL (database `forte`), its `.htconfig.php` written to
`.state/forte`, a cron sidecar (`src/Daemon/Run.php Cron`). The pasture bundle is mounted over `library/cacert.pem`, `.state/forte`, a cron sidecar (`src/Daemon/Run.php Cron`). The pasture bundle is mounted over `library/cacert.pem`,
+1
View File
@@ -46,6 +46,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d
- **Vernissage 1.43.0** - **Vernissage 1.43.0**
- **Hubzilla 11.4.1** with its pubcrawl addon - **Hubzilla 11.4.1** with its pubcrawl addon
- **Forte 26.9.10** (portable identities: actors served through `/.well-known/apgateway/did:key:…`) - **Forte 26.9.10** (portable identities: actors served through `/.well-known/apgateway/did:key:…`)
- **Castopod 1.15.5** (podcasts as actors, their episodes with the sound)
- **Activity-Relay 2.0.9** and **aode-relay 0.3.129**, as relays PrivaPub reads from - **Activity-Relay 2.0.9** and **aode-relay 0.3.129**, as relays PrivaPub reads from
- in the town only (a seeded community checked server by server): **Hollo 0.9.19**, **Iceshrimp.NET 2026.1.2-beta**, - in the town only (a seeded community checked server by server): **Hollo 0.9.19**, **Iceshrimp.NET 2026.1.2-beta**,
**Pleroma 2.10.2** **Pleroma 2.10.2**
+16
View File
@@ -854,6 +854,22 @@ without a port, before it gives out its OAuth client.
never as the relay's boost), and nobody's home; a persona's public post goes to the relays, nothing less public, and never as the relay's boost), and nobody's home; a persona's public post goes to the relays, nothing less public, and
reaches Mastodon through both (forwarded on its proof, and announced). reaches Mastodon through both (forwarded on its proof, and announced).
### Castopod 1.15.5
- **A podcast is the actor** (`@pod@castopod.test`); its people are not. It accepts follows by itself.
- **An episode goes out as a Note that only links to the episode's page**; the page serves a `PodcastEpisode` (its words
in `description`, its sound in `audio`, its cover in `image`). PrivaPub reads `PodcastEpisode`, and a Note that is
only a link to its author's episode takes the episode's sound, title, cover and words (one signed fetch), so the post
plays.
- **Publishes NodeInfo2 only** (`/.well-known/x-nodeinfo2`); PrivaPub's server description falls back to it.
- **Running it:** the official image on the shared MySQL with a file cache; `spark install:init-database` (a second run
stops before seeding: `db:seed AppSeeder`) and `install:create-superadmin`; a podcast is made and published only
through its admin pages (a 1400px square cover, a 3:1 banner and an empty `location_name` are required, or the form
fails); episodes through its REST API (`restapi.*` appended to the `.env` it writes at each start); its fediverse
queue goes out through `spark fediverse:broadcast`.
- **Pasture evidence (2026-10-06, `tools/pasture/scenarios/castopod.sh`):** 15 checks pass: alice follows the podcast,
an episode reaches her with its sound, her like, boost and comment land there, the unfollow, statistics.
### Forte 26.9.10 ### Forte 26.9.10
- **Portable identities (FEP-ef61):** a channel's actor is `https://<server>/.well-known/apgateway/did:key:z6Mk…/actor`, - **Portable identities (FEP-ef61):** a channel's actor is `https://<server>/.well-known/apgateway/did:key:z6Mk…/actor`,
+5
View File
@@ -108,6 +108,11 @@ nodebb.test {
reverse_proxy pasture-nodebb:4567 reverse_proxy pasture-nodebb:4567
} }
castopod.test {
tls internal
reverse_proxy pasture-castopod:8080
}
forte.test { forte.test {
tls internal tls internal
reverse_proxy pasture-forte:8080 reverse_proxy pasture-forte:8080
+96
View File
@@ -0,0 +1,96 @@
# Castopod 1.15.5: podcasts as ActivityPub actors, their episodes as posts with the audio. The official image (FrankenPHP
# on 8080) on the shared MySQL (database castopod), its cache in files (the shared Redis has no database left), HTTPS
# left to Caddy (CP_DISABLE_HTTPS). It trusts Caddy's CA through the bundle mounted as its system store. Its database
# and superadmin come from its spark commands; its fediverse queue goes out only through `spark fediverse:broadcast`,
# run each minute in the container. A podcast is made through its admin pages (they alone take one); episodes through
# its REST API, switched on with basic authentication.
CASTOPOD_IMAGE=${CASTOPOD_IMAGE:-docker.io/castopod/castopod:1.15.5}
CASTOPOD_PASSWORD=Castopod-Pasture-1
CASTOPOD_API=pasture:Castopod-Api-1
. "$here/peers/shared.sh"
castopod_spark() { podman exec -w /var/www/html pasture-castopod php spark "$@"; }
castopod_up() {
shared_mysql_up
mysql_db castopod
mkdir -p "$here/.state/castopod"
[ -s "$here/.state/castopod/env" ] || {
echo "CP_BASEURL=https://castopod.test"
echo "CP_MEDIA_BASEURL=https://castopod.test"
echo -e "CP_DATABASE_HOSTNAME=mysql\nCP_DATABASE_NAME=castopod\nCP_DATABASE_USERNAME=pasture\nCP_DATABASE_PASSWORD=pasture"
echo "CP_ANALYTICS_SALT=$(openssl rand -hex 32)"
echo -e "CP_CACHE_HANDLER=file\nCP_DISABLE_HTTPS=1"
} > "$here/.state/castopod/env"
podman volume exists pasture-castopod-media || podman volume create --label pasture=1 pasture-castopod-media >/dev/null
podman run -d --replace --name pasture-castopod --network $net --label pasture=1 --env-file "$here/.state/castopod/env" \
-v pasture-castopod-media:/var/www/html/public/media -v "$ca/bundle.pem:/etc/ssl/certs/ca-certificates.crt:z,ro" \
"$CASTOPOD_IMAGE" >/dev/null
for _ in $(seq 1 90); do
podman exec pasture-castopod test -f /var/www/html/.env 2>/dev/null && castopod_spark list >/dev/null 2>&1 && break
sleep 2
done
castopod_spark install:init-database >/dev/null 2>&1
# (a second init stops at the first migration it already ran, before seeding: the categories and languages it needs)
[ "$(podman exec pasture-mysql mysql -upasture -ppasture castopod -Nse "select count(*) from cp_categories" 2>/dev/null)" != "0" ] \
|| castopod_spark db:seed AppSeeder >/dev/null 2>&1
printf '%s\n%s\n' "$CASTOPOD_PASSWORD" "$CASTOPOD_PASSWORD" \
| podman exec -i -w /var/www/html pasture-castopod php spark install:create-superadmin -n admin -e admin@castopod.test >/dev/null 2>&1 || true
# its REST API, which makes and publishes episodes, with basic authentication
# (the image writes .env anew at each start, read-only)
podman exec -u root pasture-castopod sh -c "chmod u+w /var/www/html/.env; grep -q '^restapi.enabled' /var/www/html/.env \
|| printf 'restapi.enabled=true\nrestapi.basicAuth=true\nrestapi.basicAuthUsername=${CASTOPOD_API%%:*}\nrestapi.basicAuthPassword=${CASTOPOD_API#*:}\n' >> /var/www/html/.env; chmod a-w /var/www/html/.env"
podman exec -d -w /var/www/html pasture-castopod sh -c 'while true; do php spark fediverse:broadcast >/dev/null 2>&1; sleep 30; done'
for _ in $(seq 1 60); do
site castopod.test -s -o /dev/null -w '%{http_code}' https://castopod.test:6443/.well-known/nodeinfo 2>/dev/null | grep -q 200 && break
sleep 2
done
castopod_podcast
echo "castopod: https://castopod.test:6443"
}
# cp_web <curl args...>: Castopod's admin pages as its superadmin, signed in (a CSRF token on every form)
cp_web() {
local jar="$here/.state/castopod/admin.cookies"
if ! site castopod.test -s -b "$jar" -o /dev/null -w '%{http_code}' https://castopod.test:6443/cp-admin | grep -q 200; then
rm -f "$jar"
local token
token=$(site castopod.test -s -c "$jar" https://castopod.test:6443/cp-auth/login | grep -oE 'name="csrf_test_name" value="[^"]+"' | head -1 | sed 's/.*value="//;s/"//')
site castopod.test -s -o /dev/null -b "$jar" -c "$jar" -X POST https://castopod.test:6443/cp-auth/login --data-urlencode "csrf_test_name=$token" \
-d email=admin@castopod.test --data-urlencode "password=$CASTOPOD_PASSWORD"
fi
site castopod.test -s -b "$jar" -c "$jar" "$@"
}
cp_token() { cp_web "https://castopod.test:6443$1" | grep -oE 'name="csrf_test_name" value="[^"]+"' | head -1 | sed 's/.*value="//;s/"//'; }
# square(file, side) / banner: the pictures a podcast must have (a 1400px square cover, a 3:1 banner)
cp_picture() { python3 -c "
import struct,zlib
w,h=$2,$3
raw=(b'\x00'+bytes([40,120,200])*w)*h
png=b'\x89PNG\r\n\x1a\n'+b''.join(struct.pack('>I',len(c))+t+c+struct.pack('>I',zlib.crc32(t+c)&0xffffffff) for t,c in [(b'IHDR',struct.pack('>IIBBBBB',w,h,8,2,0,0,0)),(b'IDAT',zlib.compress(raw,9)),(b'IEND',b'')])
open('$1','wb').write(png)"; }
# the podcast @pod: only its admin pages make one (its banner is required too, and an absent location fails it)
castopod_podcast() {
[ -n "$(podman exec pasture-mysql mysql -upasture -ppasture castopod -Nse "select id from cp_podcasts where handle = 'pod'" 2>/dev/null)" ] && return 0
local category
category=$(podman exec pasture-mysql mysql -upasture -ppasture castopod -Nse "select id from cp_categories where code = 'technology'" 2>/dev/null)
cp_picture "$here/.state/castopod/cover.png" 1400 1400
cp_picture "$here/.state/castopod/banner.png" 1500 500
cp_web -o /dev/null -X POST https://castopod.test:6443/cp-admin/podcasts/new -F "csrf_test_name=$(cp_token /cp-admin/podcasts/new)" \
-F "cover=@$here/.state/castopod/cover.png;type=image/png" -F "banner=@$here/.state/castopod/banner.png;type=image/png" \
-F "title=Pasture Podcast" -F "description=A podcast of the pasture" -F type=episodic -F medium=podcast -F language=en \
-F "category=$category" -F parental_advisory=clean -F owner_name=Pasture -F owner_email=admin@castopod.test -F handle=pod \
-F location_name= -F custom_rss= -F verify_txt=
castopod_publish_podcast
}
# a podcast made is a draft: its pages, its episodes' pages and their objects answer 404 until it is published
castopod_publish_podcast() {
[ "$(podman exec pasture-mysql mysql -upasture -ppasture castopod -Nse "select published_at is not null from cp_podcasts where handle = 'pod'" 2>/dev/null)" = "1" ] && return 0
local token
token=$(cp_web https://castopod.test:6443/cp-admin/podcasts/1/publish | grep -oE 'name="csrf_test_name" value="[^"]+"' | tail -1 | sed 's/.*value="//;s/"//')
cp_web -o /dev/null -X POST https://castopod.test:6443/cp-admin/podcasts/1/publish --data-urlencode "csrf_test_name=$token" \
-d client_timezone=UTC -d publication_method=now --data-urlencode "message=The pasture's podcast is on the air"
}
+59
View File
@@ -0,0 +1,59 @@
# Castopod 1.15.5: alice follows the podcast @pod; an episode published through its REST API reaches her with its audio;
# her like, reply and boost land on it there; the unfollow; statistics (from NodeInfo2, all Castopod publishes). The
# episode's sound is made with ffmpeg; Castopod's own fediverse tables (cp_fediverse_*) say what it holds.
. "$here/peers/castopod.sh"
CP=https://castopod.test:6443
cpc() { site castopod.test -s -u "$CASTOPOD_API" "$@"; }
cp_sql() { podman exec pasture-mysql mysql -upasture -ppasture castopod -Nse "$1" 2>/dev/null; }
p_home_has() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next(((s.get('reblog') or s)['id'] for s in d if '$1' in ((s.get('reblog') or s)['content'] or '') or '$1' in (((s.get('reblog') or s).get('privapub') or {}).get('title') or '')), ''))"; }
p_status() { curl -s -H "$PH" "$P/api/v1/statuses/$1" | j "print(d.get('$2'))"; }
echo "castopod"
[ "$(cpc "$CP/api/rest/v1/podcasts" | j "print(next((p['handle'] for p in d), ''))")" = "pod" ] && ok "Castopod's REST API and the podcast @pod" \
|| { ko "Castopod's REST API ($(cpc "$CP/api/rest/v1/podcasts" | head -c 200))"; return 1; }
PT=$(privapub_token alice_castopod)
PH="Authorization: Bearer $PT"
[ -n "$PT" ] && ok "PrivaPub token for alice_castopod" || { ko "PrivaPub token for alice_castopod"; return 1; }
run=$(date +%s)
echo " follows"
pod_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=pod@castopod.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$pod_on_p" ] && ok "PrivaPub resolves the podcast @pod" || ko "PrivaPub cannot resolve @pod"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$pod_on_p/follow"
until_true 60 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$pod_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \
&& ok "alice follows @pod (Accept arrived)" || ko "Castopod's Accept never arrived"
echo " episodes"
mp3="$here/.state/castopod/episode.mp3"
[ -s "$mp3" ] || ffmpeg -loglevel error -f lavfi -i "sine=frequency=440:duration=4" -q:a 9 "$mp3"
episode=$(cpc -X POST "$CP/api/rest/v1/episodes" -F created_by=1 -F updated_by=1 -F podcast_id=1 -F "title=A pasture episode $run" \
-F "slug=a-pasture-episode-$run" -F "description=Listen to the meadow $run" -F type=full -F parental_advisory=clean -F location_name= \
-F custom_rss= -F "audio_file=@$mp3;type=audio/mpeg" | j "print(d.get('id', ''))")
[ -n "$episode" ] && ok "Castopod makes the episode" || ko "Castopod refused the episode"
cpc -o /dev/null -X POST "$CP/api/rest/v1/episodes/$episode/publish" -F created_by=1 -F publication_method=now -F client_timezone=UTC
castopod_spark fediverse:broadcast >/dev/null 2>&1
until_true 90 '[ -n "$(p_home_has "A pasture episode $run")" ] || [ -n "$(p_home_has "Listen to the meadow $run")" ]' \
&& ok "the episode reaches alice's home" || ko "the episode never reached alice"
ep_on_p=$(p_home_has "A pasture episode $run"); [ -n "$ep_on_p" ] || ep_on_p=$(p_home_has "Listen to the meadow $run")
[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$ep_on_p" | j "print(any(m['type'] == 'audio' for m in d['media_attachments']) or bool((d.get('privapub') or {}).get('audio')))")" = "True" ] \
&& ok "with its audio" || ko "the episode arrived without its audio ($(curl -s -H "$PH" "$P/api/v1/statuses/$ep_on_p" | head -c 300))"
ep_uri=$(p_status "$ep_on_p" uri)
echo " alice answers"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$ep_on_p/favourite"
until_true 60 '[ "$(cp_sql "select count(*) from cp_fediverse_favourites f join cp_fediverse_actors a on a.id = f.actor_id where a.uri like '"'%alice_castopod%'"'")" -ge 1 ]' \
&& ok "alice's like lands on Castopod" || ko "alice's like never reached Castopod"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$ep_on_p/reblog"
until_true 60 '[ "$(cp_sql "select count(*) from cp_fediverse_posts p join cp_fediverse_actors a on a.id = p.actor_id where a.uri like '"'%alice_castopod%'"' and p.reblog_of_id is not null")" -ge 1 ]' \
&& ok "alice's boost lands on Castopod" || ko "alice's boost never reached Castopod"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses" -d "status=@pod@castopod.test a PrivaPub comment $run&in_reply_to_id=$ep_on_p&visibility=public"
until_true 60 '[ "$(cp_sql "select count(*) from cp_fediverse_posts where message like '"'%a PrivaPub comment $run%'"' and in_reply_to_id is not null")" -ge 1 ]' \
&& ok "alice's comment threads under the episode" || ko "alice's comment never reached Castopod"
echo " unfollow"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$pod_on_p/unfollow"
until_true 60 '[ "$(cp_sql "select count(*) from cp_fediverse_follows f join cp_fediverse_actors a on a.id = f.actor_id where a.uri like '"'%alice_castopod%'"'")" = "0" ]' \
&& ok "alice's unfollow reaches Castopod" || ko "Castopod still counts alice"
echo " statistics"
stats_check castopod.test castopod