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
@@ -108,6 +108,11 @@ nodebb.test {
reverse_proxy pasture-nodebb:4567
}
castopod.test {
tls internal
reverse_proxy pasture-castopod:8080
}
forte.test {
tls internal
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