Pasture: PeerTube joins, and its videos play through us

PeerTube 8.3.1 runs on the shared Postgres and Redis, transcoding off. Its
scenario passes 24 checks: a persona follows a channel; a new video comes as
the channel's Announce and reaches the persona's home as a boost, playable
through the media proxy with byte ranges; comments both ways thread; a like
and its undo count; renaming and deletion arrive; the unfollow; statistics.

The town's seeder also takes reruns on the same accounts in its stride: a
Lemmy community already made is found, a circle member already approved
asks nothing again.

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-05 03:52:29 +02:00
1 parent d61eac0a01
commit 40217f3261
8 files changed
+186 -1

No files matched your search

+4
View File
@@ -494,6 +494,10 @@ tools/pasture/run.sh down # removes e
- **Hollo (0.9.19):** Fedify's microblog server on the shared Postgres, set up through its web form (which - **Hollo (0.9.19):** Fedify's microblog server on the shared Postgres, set up through its web form (which
checks `Origin` against `Host`, so the request names `hollo.test` without the port). It needs a 44-character checks `Origin` against `Host`, so the request names `hollo.test` without the port). It needs a 44-character
`SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario. `SECRET_KEY`, a media directory and a `themeColor`; statuses and votes go as JSON. Town only, no scenario.
- **PeerTube (8.3.1):** the official image on the shared Postgres and Redis (db 4), configured through `PEERTUBE_*`
variables, trusting Caddy's CA through `NODE_EXTRA_CA_CERTS`; `peertube_settle` turns transcoding off (a test video
is served as uploaded) and keeps root's token in `.state/peertube.token`. It wants a bare `Host: peertube.test`.
`scenarios/peertube.sh`, 24 checks.
- **Pleroma (2.10.2):** `images/pleroma` installs the OTP release, pinned by checksum, as Akkoma's does; it also needs - **Pleroma (2.10.2):** `images/pleroma` installs the OTP release, pinned by checksum, as Akkoma's does; it also needs
libvips, and `instance gen` asks about deduplicating uploads. Its federation runs on hackney, which trusts only libvips, and `instance gen` asks about deduplicating uploads. Its federation runs on hackney, which trusts only
certifi's compiled-in roots, so the entrypoint points `:pleroma, :http, adapter` at the system CA bundle. It answers certifi's compiled-in roots, so the entrypoint points `:pleroma, :http, adapter` at the system CA bundle. It answers
+12
View File
@@ -635,6 +635,18 @@ attachment. The player is a card whose iframe loads from the remote host, which
| Dislike counts; live state through `Update`; chapters and captions | P2 | own | | Dislike counts; live state through `Update`; chapters and captions | P2 | own |
| Optionally, `View` sent from the instance actor (so it never names a persona) | P3 | — | | Optionally, `View` sent from the instance actor (so it never names a persona) | P3 | — |
**Pasture evidence (2026-10-05, PeerTube 8.3.1, `tools/pasture/scenarios/peertube.sh`):** 24 checks pass:
- a persona finds a channel (a group account) and its owner's account, and follows the channel (accepted at once);
- a new video comes as the channel's `Announce`, not a `Create`, so it reaches the persona's home as the channel's boost
of the video, with a playable attachment served through the media proxy, which answers byte ranges;
- a reply to the video is a comment on PeerTube, and an answer to that comment threads under the video here;
- a like and its undo count on PeerTube; the video's renaming (`Update`) and its deletion reach us;
- the unfollow, and statistics.
PeerTube's own instance account announces each new video too, which we drop: nobody here follows it. Its users can
follow only PeerTube-like channels and accounts, never a persona. It checks the `Host` header against its own name,
without a port, before it gives out its OAuth client.
### Loops (1.0.0-beta.14) and Pixelfed (0.14.4) ### Loops (1.0.0-beta.14) and Pixelfed (0.14.4)
- **Loops:** - **Loops:**
+5
View File
@@ -57,3 +57,8 @@ pleroma.test {
tls internal tls internal
reverse_proxy pasture-pleroma:4000 reverse_proxy pasture-pleroma:4000
} }
peertube.test {
tls internal
reverse_proxy pasture-peertube:9000
}
+1 -1
View File
@@ -1,6 +1,6 @@
# Iceshrimp.NET (2026.1): the strict one. AuthorizedFetch on (every fetch from it is signed, and it answers unsigned # Iceshrimp.NET (2026.1): the strict one. AuthorizedFetch on (every fetch from it is signed, and it answers unsigned
# reads 401), full JSON-LD expansion that drops undefined terms, several reactions per user. .NET, so it trusts Caddy's CA # reads 401), full JSON-LD expansion that drops undefined terms, several reactions per user. .NET, so it trusts Caddy's CA
# through SSL_CERT_FILE. On the shared Postgres (database iceshrimp); the town makes its users with its own CLI. # through SSL_CERT_FILE. On the shared Postgres (database iceshrimp); the town makes its users through its own API.
ICESHRIMP_IMAGE=${ICESHRIMP_IMAGE:-iceshrimp.dev/iceshrimp/iceshrimp.net:v2026.1.2-beta} ICESHRIMP_IMAGE=${ICESHRIMP_IMAGE:-iceshrimp.dev/iceshrimp/iceshrimp.net:v2026.1.2-beta}
. "$here/peers/shared.sh" . "$here/peers/shared.sh"
+59
View File
@@ -0,0 +1,59 @@
# PeerTube 8.3: videos as ActivityPub objects (Video with its files, HLS playlists, captions and storyboards), comments
# as Notes, likes and dislikes, accounts and channels as separate actors. On the shared Postgres (database peertube) and
# Redis (db 4). Node, so it trusts Caddy's CA through NODE_EXTRA_CA_CERTS; its SSRF guard lets the pasture's public-looking
# subnet through. Transcoding is turned off once it is up, so a test video is served as it was uploaded. Its admin is
# root, with the password below; the town makes its users through the admin API.
PEERTUBE_IMAGE=${PEERTUBE_IMAGE:-docker.io/chocobozzz/peertube:v8.3.1}
PEERTUBE_ROOT_PASSWORD=Peertube-Pasture-Root-1
. "$here/peers/shared.sh"
peertube_up() {
shared_postgres_up
shared_redis_up
pg_db peertube pg_trgm unaccent
mkdir -p "$here/.state/peertube/data" "$here/.state/peertube/config"
podman run -d --replace --name pasture-peertube --network $net \
-e PEERTUBE_WEBSERVER_HOSTNAME=peertube.test -e PEERTUBE_WEBSERVER_PORT=443 -e PEERTUBE_WEBSERVER_HTTPS=true \
-e PEERTUBE_TRUST_PROXY='["loopback", "linklocal", "uniquelocal", "'"$subnet"'"]' \
-e PEERTUBE_DB_HOSTNAME=postgres -e PEERTUBE_DB_NAME=peertube -e PEERTUBE_DB_SUFFIX= \
-e PEERTUBE_DB_USERNAME=pasture -e PEERTUBE_DB_PASSWORD=pasture \
-e PEERTUBE_REDIS_HOSTNAME=redis -e PEERTUBE_REDIS_DB=4 \
-e PEERTUBE_SECRET=pasture-peertube-secret-0123456789abcdef -e PEERTUBE_ADMIN_EMAIL=admin@peertube.test \
-e PT_INITIAL_ROOT_PASSWORD=$PEERTUBE_ROOT_PASSWORD \
-e PEERTUBE_RATES_LIMIT_API_MAX=100000 -e PEERTUBE_RATES_LIMIT_LOGIN_MAX=100000 -e PEERTUBE_RATES_LIMIT_ACTIVITY_PUB_MAX=100000 \
-e PEERTUBE_SIGNUP_ENABLED=true \
-e NODE_EXTRA_CA_CERTS=/pasture/ca/root.crt \
-v "$here/.state/peertube/data:/data:Z" -v "$here/.state/peertube/config:/config:Z" -v "$ca:/pasture/ca:z,ro" \
"$PEERTUBE_IMAGE" >/dev/null
for _ in $(seq 1 150); do
site peertube.test -s -o /dev/null -w '%{http_code}' https://peertube.test:6443/api/v1/config 2>/dev/null | grep -q 200 && break
sleep 3
done
peertube_settle
echo "peertube: https://peertube.test:6443"
}
# root's token (the password grant with the instance's own client); transcoding off, signups open, any import allowed
peertube_settle() {
local client token
# PeerTube gives its client only to its own host, named without the port
client=$(site peertube.test -s -H "Host: peertube.test" https://peertube.test:6443/api/v1/oauth-clients/local)
token=$(site peertube.test -s -X POST https://peertube.test:6443/api/v1/users/token \
--data-urlencode "client_id=$(echo "$client" | python3 -c 'import sys,json; print(json.load(sys.stdin)["client_id"])')" \
--data-urlencode "client_secret=$(echo "$client" | python3 -c 'import sys,json; print(json.load(sys.stdin)["client_secret"])')" \
--data-urlencode grant_type=password --data-urlencode username=root --data-urlencode "password=$PEERTUBE_ROOT_PASSWORD" \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')
echo "$token" > "$here/.state/peertube.token"
site peertube.test -s https://peertube.test:6443/api/v1/config/custom -H "Authorization: Bearer $token" | python3 -c '
import sys, json
c = json.load(sys.stdin)
c["transcoding"]["enabled"] = False
c["signup"]["enabled"] = True
c["signup"]["requiresEmailVerification"] = False
c["signup"]["limit"] = -1
c["user"]["videoQuota"] = -1
c["user"]["videoQuotaDaily"] = -1
print(json.dumps(c))' > "$here/.state/peertube/custom.json"
site peertube.test -s -o /dev/null -w '%{http_code}\n' -X PUT https://peertube.test:6443/api/v1/config/custom -H "Authorization: Bearer $token" \
-H 'Content-Type: application/json' --data @"$here/.state/peertube/custom.json"
}
+94
View File
@@ -0,0 +1,94 @@
# PeerTube 8.3: a persona follows a channel; the channel's video arrives as a playable post, its file streamed through
# PrivaPub's media proxy with byte ranges; comments both ways thread; a like counts and its undo too; the video's edit
# and its deletion reach PrivaPub; statistics. PeerTube's users follow channels and accounts of PeerTube-like servers
# only, so nothing here has PeerTube following a persona.
PTB=https://peertube.test:6443
# PeerTube checks the Host against its own name, without the port
pcurl() { curl -sk --resolve peertube.test:6443:127.0.0.1 -H "Host: peertube.test" "$@"; }
PTROOT=$(cat "$here/.state/peertube.token" 2>/dev/null)
PTPASS=Peertube-Pasture-User-1
# a user's token through the instance's own OAuth client
pt_token() {
local client
client=$(pcurl "$PTB/api/v1/oauth-clients/local")
pcurl -X POST "$PTB/api/v1/users/token" \
--data-urlencode "client_id=$(echo "$client" | j "print(d['client_id'])")" \
--data-urlencode "client_secret=$(echo "$client" | j "print(d['client_secret'])")" \
--data-urlencode grant_type=password --data-urlencode "username=$1" --data-urlencode "password=$2" | j "print(d['access_token'])"
}
# a PrivaPub status in alice_peertube's home whose text or title has the given words; a channel shares its videos by
# boosting them, so it is the boosted status
p_home_id() { curl -s -H "$PH" "$P/api/v1/timelines/home?limit=40" | j "print(next((o['id'] for o in ((s.get('reblog') or s) for s in d) if '$1' in (o['content'] or '') or '$1' in ((o.get('privapub') or {}).get('title') or '')), ''))"; }
echo "peertube"
[ -n "$PTROOT" ] && ok "PeerTube root token" || { ko "PeerTube root token"; return 1; }
pcurl -o /dev/null -X POST -H "Authorization: Bearer $PTROOT" -H 'Content-Type: application/json' "$PTB/api/v1/users" \
-d "{\"username\":\"ptuser\",\"password\":\"$PTPASS\",\"email\":\"ptuser@peertube.test\",\"role\":2,\"videoQuota\":-1,\"videoQuotaDaily\":-1}"
TT=$(pt_token ptuser "$PTPASS")
TH="Authorization: Bearer $TT"
[ -n "$TT" ] && ok "PeerTube token for ptuser" || { ko "PeerTube token for ptuser"; return 1; }
channel=$(pcurl -H "$TH" "$PTB/api/v1/users/me" | j "print(d['videoChannels'][0]['name'])")
channel_id=$(pcurl -H "$TH" "$PTB/api/v1/users/me" | j "print(d['videoChannels'][0]['id'])")
PT=$(privapub_token alice_peertube)
PH="Authorization: Bearer $PT"
[ -n "$PT" ] && ok "PrivaPub token for alice_peertube" || { ko "PrivaPub token for alice_peertube"; return 1; }
echo " discovery and follows"
channel_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=$channel@peertube.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$channel_on_p" ] && ok "PrivaPub resolves the channel @$channel@peertube.test" || ko "PrivaPub cannot resolve the channel"
[ "$(curl -s -H "$PH" "$P/api/v1/accounts/$channel_on_p" | j "print(d['group'])")" = "True" ] && ok "the channel is a group account" || ko "the channel is not shown as a group"
account_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=ptuser@peertube.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
[ -n "$account_on_p" ] && ok "PrivaPub resolves the account @ptuser@peertube.test" || ko "PrivaPub cannot resolve the account"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$channel_on_p/follow"
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$channel_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \
&& ok "alice_peertube follows the channel (Accept arrived)" || ko "the channel's Accept never arrived"
until_true 30 '[ "$(pcurl -H "$TH" "$PTB/api/v1/video-channels/$channel/followers" | j "print(d[\"total\"])")" -ge 1 ]' \
&& ok "PeerTube lists alice_peertube among the channel's followers" || ko "PeerTube does not list the follower"
echo " videos"
video=$(pcurl -X POST -H "$TH" "$PTB/api/v1/videos/upload" -F "videofile=@$here/town/media/tiny.mp4;type=video/mp4" \
-F "channelId=$channel_id" -F "name=Hello PrivaPub from PeerTube" -F "description=a tiny test video" -F privacy=1 -F commentsPolicy=1 -F waitTranscoding=false)
video_id=$(echo "$video" | j "print(d['video']['shortUUID'])")
[ -n "$video_id" ] && ok "ptuser uploads a video" || ko "upload refused: $(echo "$video" | head -c 200)"
until_true 60 '[ -n "$(p_home_id "Hello PrivaPub from PeerTube")" ]' && ok "the video reaches alice_peertube's home, boosted by its channel" || ko "the video never arrived"
v_on_p=$(p_home_id "Hello PrivaPub from PeerTube")
v_status=$(curl -s -H "$PH" "$P/api/v1/statuses/$v_on_p")
[ "$(echo "$v_status" | j "print(d['media_attachments'][0]['type'])")" = "video" ] && ok "the video is a playable attachment" || ko "no video attachment: $(echo "$v_status" | j "print(d['media_attachments'])" | head -c 200)"
v_file=$(echo "$v_status" | j "print(d['media_attachments'][0]['url'])")
case "$v_file" in
*privapub.test/*) ok "its file is served through PrivaPub's proxy" ;;
*) ko "its file is not proxied: $v_file" ;;
esac
range=$(pfetch -s -o /dev/null -w '%{http_code} %{size_download}' -H 'Range: bytes=0-99' "$v_file")
[ "$range" = "206 100" ] && ok "the proxy answers a byte range (206, 100 bytes)" || ko "the proxy did not answer the range: $range"
echo " comments"
curl -s -o /dev/null -X POST -H "$PH" $P/api/v1/statuses -d "status=@ptuser@peertube.test lovely video&in_reply_to_id=$v_on_p&visibility=public"
until_true 30 '[ "$(pcurl "$PTB/api/v1/videos/$video_id/comment-threads" | j "print(any(\"lovely video\" in c[\"text\"] for c in d[\"data\"]))")" = "True" ]' \
&& ok "alice_peertube's reply is a comment on the video" || ko "the reply never became a comment"
thread=$(pcurl "$PTB/api/v1/videos/$video_id/comment-threads" | j "print(next(c['id'] for c in d['data'] if 'lovely video' in c['text']))")
pcurl -o /dev/null -X POST -H "$TH" -H 'Content-Type: application/json' "$PTB/api/v1/videos/$video_id/comments/$thread" -d '{"text":"thank you from PeerTube"}'
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$v_on_p/context" | j "print(any(\"thank you from PeerTube\" in s[\"content\"] for s in d[\"descendants\"]))")" = "True" ]' \
&& ok "ptuser's answer threads under the video on PrivaPub" || ko "PeerTube's answer missing from the thread"
echo " likes"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$v_on_p/favourite"
until_true 30 '[ "$(pcurl "$PTB/api/v1/videos/$video_id" | j "print(d[\"likes\"])")" = "1" ]' && ok "alice_peertube's like counts on PeerTube" || ko "like not counted on PeerTube"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/statuses/$v_on_p/unfavourite"
until_true 30 '[ "$(pcurl "$PTB/api/v1/videos/$video_id" | j "print(d[\"likes\"])")" = "0" ]' && ok "alice_peertube's unlike reaches PeerTube" || ko "unlike not applied on PeerTube"
echo " edits and deletes"
pcurl -o /dev/null -X PUT -H "$TH" -H 'Content-Type: application/json' "$PTB/api/v1/videos/$video_id" -d '{"name":"Hello again from PeerTube","description":"renamed"}'
until_true 30 '[ "$(curl -s -H "$PH" "$P/api/v1/statuses/$v_on_p" | j "print(\"Hello again from PeerTube\" in json.dumps(d))")" = "True" ]' \
&& ok "the video's new name reaches PrivaPub" || ko "the edit never arrived"
pcurl -o /dev/null -X DELETE -H "$TH" "$PTB/api/v1/videos/$video_id"
until_true 30 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$v_on_p")" = "404" ]' \
&& ok "the video's deletion reaches PrivaPub" || ko "the deleted video is still on PrivaPub"
echo " unfollow"
curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$channel_on_p/unfollow"
until_true 30 '[ "$(pcurl -H "$TH" "$PTB/api/v1/video-channels/$channel/followers" | j "print(d[\"total\"])")" = "0" ]' \
&& ok "the unfollow reaches PeerTube" || ko "PeerTube still lists alice_peertube"
echo " statistics"
stats_check peertube.test peertube
+6
View File
@@ -77,7 +77,13 @@ class LemmyApi(Driver):
# -- communities and people # -- communities and people
def community(self, s, name, title): def community(self, s, name, title):
try:
return self.lm(s, "POST", "community", {"name": name, "title": title})["community_view"]["community"] return self.lm(s, "POST", "community", {"name": name, "title": title})["community_view"]["community"]
except HttpError as e:
# a run on accounts an earlier run made finds the community it made
if "already_exists" not in str(e):
raise
return self.lm(s, "GET", "community", params={"name": name})["community_view"]["community"]
def resolve_community(self, s, ref): def resolve_community(self, s, ref):
"""ref: !name@host""" """ref: !name@host"""
+5
View File
@@ -261,6 +261,11 @@ class Seeder:
member=member, state="local") member=member, state="local")
return return
member_uri = self.session(member).actor_uri member_uri = self.session(member).actor_uri
# a member an earlier run on these accounts approved asks nothing again
if podman.mongo(f"db.Follower.findOne({{LocalActorId: '{group['id']}', ActorURI: '{member_uri}', IsAccepted: true}}, {{_id: 1}})"):
self.ledger.add(type="relation", n=s["n"], verb="approve", actor=s["actor"], group=s["args"]["group"],
member=member, state="accepted", already=True)
return
for _ in range(30): for _ in range(30):
found = podman.mongo(f"db.Follower.findOne({{LocalActorId: '{group['id']}', IsAccepted: false}}, {{ActorURI: 1}})") found = podman.mongo(f"db.Follower.findOne({{LocalActorId: '{group['id']}', IsAccepted: false}}, {{ActorURI: 1}})")
pending = podman.mongo(f"db.Follower.find({{LocalActorId: '{group['id']}', IsAccepted: false}}, {{ActorURI: 1}}).toArray()") or [] pending = podman.mongo(f"db.Follower.find({{LocalActorId: '{group['id']}', IsAccepted: false}}, {{ActorURI: 1}}).toArray()") or []