diff --git a/CLAUDE.md b/CLAUDE.md index 2b93101..15212d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -560,6 +560,11 @@ tools/pasture/run.sh down # removes e Application actor, `relay`, publishes the agenda. Its API takes a token from an OAuth password grant (`/oauth/login`, client_id `self`); an event is a multipart POST (`--form-string` for text starting with `<`). `scenarios/gancio.sh`, 17 checks. +- **Funkwhale (2.0.11):** the API (gunicorn), a Celery worker with its beat and the front's nginx, sharing the + `pasture-funkwhale-data` volume, on the shared Postgres (database funkwhale) and Redis (dbs 8 and 9); Python's requests + trusts the pasture's bundle through `REQUESTS_CA_BUNDLE`. `funkwhale-manage fw users create` makes fwuser and an OAuth + access token is made in its Django shell. Its API is `/api/v1` and `/api/v2` (uploads); a track is a fresh tone made + by ffmpeg in its container, since it skips a file it already has. `scenarios/funkwhale.sh`, 16 checks. - **SecureMode:** `PRIVAPUB_ENV="Federation__SecureMode=true" run.sh up …`, as production runs. A check of what an unsigned reader sees uses `unserved` and `gone_unsigned` (`lib/interop.sh`), which expect 401 when SecureMode is on and 404 or 410 when it is off. diff --git a/FEDERATION.md b/FEDERATION.md index 90ccc36..18dd767 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -28,6 +28,7 @@ and every run starting clean, with signed fetches required (as privapub.thepra.d - **Friendica 2026.05** - **Mobilizon 5.2.4** - **Gancio 1.28.2** +- **Funkwhale 2.0.11** - 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** diff --git a/docs/INTEROP.md b/docs/INTEROP.md index 3f33494..3d479bb 100644 --- a/docs/INTEROP.md +++ b/docs/INTEROP.md @@ -831,11 +831,22 @@ FEP-8a8e (draft) is the common reference. - `Create{Audio}` whose `url` is an array: a page link plus the stream, with `bitrate` and `size`. - Also `duration`, `position`, `disc`, `album`, `license` and `image`; `summary` is a hashtag line (W2). - `Listen{Track}`. + - Its Accept names our Follow under an id of its own on our origin (`…/alice#follows/`), and its own id is + that plus `/accept`: PrivaPub refused it as off its actor's origin (400) until 2026-10-05, so no follow of a + channel ever completed. + - A channel deletes uploads in one `Delete` with no id, the uploads' ids in a list as the object's `id`: PrivaPub read + no id there until 2026-10-05. + - Its NodeInfo discovery names the document under its swagger schema's URL as `rel`; PrivaPub now takes a link whose + path names NodeInfo. A channel's followers and following collections answer 500. + - **Pasture evidence (2026-10-05, Funkwhale 2.0.11, `tools/pasture/scenarios/funkwhale.sh`):** 16 checks pass: alice + follows a channel; a track uploaded to it arrives as Audio with its file and duration; its deletion; the unfollow; + statistics. - **Castopod:** an episode arrives as a link-only Note; the player comes from OpenGraph/oEmbed. Fetching the episode with the podcast type gives a `PodcastEpisode` with transcript and chapters. - **Owncast:** a `Service` actor; "go live" is a Note with a thumbnail. - **Gaps:** - - **P1:** pick the audio Link out of `url`, sniff its type, apply W2. Surfaces as `MediaAttachment{type: audio}` + - ~~**P1:** pick the audio Link out of `url`, sniff its type, apply W2~~ (Funkwhale's Audio arrives with its file and + duration, 2026-10-05). Surfaces as `MediaAttachment{type: audio}` with `meta.original.duration` and the cover as `preview_url`. - **P2:** duration, cover, album, position and licence (own `privapub.audio`); the Castopod transcript and chapters. - **P3:** `Listen` history; Owncast live state. diff --git a/tools/pasture/Caddyfile b/tools/pasture/Caddyfile index 02e95c9..9536ede 100644 --- a/tools/pasture/Caddyfile +++ b/tools/pasture/Caddyfile @@ -87,3 +87,8 @@ gancio.test { tls internal reverse_proxy pasture-gancio:13120 } + +funkwhale.test { + tls internal + reverse_proxy pasture-funkwhale:80 +} diff --git a/tools/pasture/peers/funkwhale.sh b/tools/pasture/peers/funkwhale.sh new file mode 100644 index 0000000..26a21ef --- /dev/null +++ b/tools/pasture/peers/funkwhale.sh @@ -0,0 +1,78 @@ +# Funkwhale 2.0.11: music and podcasts. A channel is an actor of its own that publishes Audio, its files in `url[]`; +# libraries follow libraries. The API (gunicorn), a Celery worker with its beat, and the front's nginx (which proxies the +# API and serves media) share the pasture-funkwhale-data volume, on the shared Postgres (database funkwhale) and Redis +# (dbs 8 and 9). Python's requests trusts the pasture's bundle through REQUESTS_CA_BUNDLE. Its admin is fwuser; a token +# is an OAuth access token made in Django's shell. +FUNKWHALE_IMAGE=${FUNKWHALE_IMAGE:-docker.io/funkwhale/api:2.0.11} +FUNKWHALE_FRONT_IMAGE=${FUNKWHALE_FRONT_IMAGE:-docker.io/funkwhale/front:2.0.11} +FUNKWHALE_PASSWORD=Funkwhale-Pasture-1 +. "$here/peers/shared.sh" + +funkwhale_env() { + local key="$here/.state/funkwhale/secret" + [ -s "$key" ] || { mkdir -p "$here/.state/funkwhale"; head -c 48 /dev/urandom | base64 -w0 > "$key"; } + cat < "$here/.state/funkwhale/env" + podman volume exists pasture-funkwhale-data || podman volume create --label pasture=1 pasture-funkwhale-data >/dev/null + local common=(--network $net --label pasture=1 --env-file "$here/.state/funkwhale/env" -v pasture-funkwhale-data:/srv/funkwhale/data + -v "$ca/bundle.pem:/ca/bundle.pem:z,ro") + podman run --rm "${common[@]}" "$FUNKWHALE_IMAGE" funkwhale-manage migrate >/dev/null 2>&1 + podman run --rm "${common[@]}" "$FUNKWHALE_IMAGE" funkwhale-manage collectstatic --noinput >/dev/null 2>&1 + podman run -d --replace --name pasture-funkwhale-api "${common[@]}" "$FUNKWHALE_IMAGE" gunicorn >/dev/null + podman run -d --replace --name pasture-funkwhale-celery "${common[@]}" "$FUNKWHALE_IMAGE" \ + celery -A funkwhale_api.taskapp worker -B -l info --concurrency=2 -s /tmp/celerybeat-schedule >/dev/null + podman run -d --replace --name pasture-funkwhale --network $net --label pasture=1 --env-file "$here/.state/funkwhale/env" \ + -v pasture-funkwhale-data:/srv/funkwhale/data "$FUNKWHALE_FRONT_IMAGE" >/dev/null + for _ in $(seq 1 90); do + site funkwhale.test -s -o /dev/null -w '%{http_code}' https://funkwhale.test:6443/.well-known/nodeinfo 2>/dev/null | grep -q 200 && break + sleep 2 + done + funkwhale_settle + echo "funkwhale: https://funkwhale.test:6443" +} + +# fwuser, and an OAuth access token of its own +funkwhale_settle() { + funkwhale_manage fw users create --username fwuser --email fwuser@funkwhale.test --password "$FUNKWHALE_PASSWORD" --superuser >/dev/null 2>&1 || true + podman exec -i pasture-funkwhale-api funkwhale-manage shell >/dev/null 2>&1 <<'PY' +from datetime import timedelta +from django.utils import timezone +from funkwhale_api.users.models import User +from funkwhale_api.users.models import Application, AccessToken +user = User.objects.get(username="fwuser") +app, _ = Application.objects.get_or_create(name="pasture", defaults={"client_type": "confidential", "authorization_grant_type": "authorization-code", "redirect_uris": "urn:ietf:wg:oauth:2.0:oob", "scope": "read write", "user": user}) +AccessToken.objects.filter(user=user, application=app).delete() +AccessToken.objects.create(user=user, application=app, token="pasture-fwuser-token", scope="read write", expires=timezone.now() + timedelta(days=365)) +PY + echo "pasture-fwuser-token" > "$here/.state/funkwhale.token" +} diff --git a/tools/pasture/scenarios/funkwhale.sh b/tools/pasture/scenarios/funkwhale.sh new file mode 100644 index 0000000..1603e55 --- /dev/null +++ b/tools/pasture/scenarios/funkwhale.sh @@ -0,0 +1,57 @@ +# Funkwhale 2.0.11: a channel (an actor of its own) and its tracks. alice_funkwhale follows the channel; a track uploaded +# to it arrives as Audio with its file and duration; its deletion; the unfollow. Funkwhale's API takes fwuser's OAuth +# token; a track is a fresh tone made by ffmpeg in its own container (Funkwhale skips a file it already has); what it +# holds is read from the shared Postgres (database funkwhale). +FWB=https://funkwhale.test:6443 +fw() { curl -sk --resolve funkwhale.test:6443:127.0.0.1 -H "Authorization: Bearer $(cat "$here/.state/funkwhale.token")" "$@"; } +fw_sql() { podman exec pasture-postgres psql -U pasture -d funkwhale -tAc "$1" 2>/dev/null; } +p_home_has() { 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 "funkwhale" +[ -s "$here/.state/funkwhale.token" ] && ok "Funkwhale token for fwuser" || { ko "Funkwhale token"; return 1; } +[ "$(fw -o /dev/null -w '%{http_code}' "$FWB/api/v2/users/me")" = "200" ] && ok "fwuser's token works" || { ko "fwuser's token is refused"; return 1; } +PT=$(privapub_token alice_funkwhale) +PH="Authorization: Bearer $PT" +[ -n "$PT" ] && ok "PrivaPub token for alice_funkwhale" || { ko "PrivaPub token for alice_funkwhale"; return 1; } + +echo " the channel and its follower" +fw -o /dev/null -X POST "$FWB/api/v1/channels/" -H 'Content-Type: application/json' -d '{"name":"Pasture sounds","username":"pasturesounds", + "description":{"text":"sounds of the pasture","content_type":"text/markdown"},"tags":["pasture"],"content_category":"music"}' +channel=$(fw "$FWB/api/v1/channels/?scope=me" | j "print(next(c['uuid'] for c in d['results'] if c['actor']['preferred_username'] == 'pasturesounds'))") +[ -n "$channel" ] && ok "fwuser runs the channel pasturesounds" || ko "the channel could not be made" +channel_on_p=$(curl -s -H "$PH" "$P/api/v2/search?q=pasturesounds@funkwhale.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") +[ -n "$channel_on_p" ] && ok "PrivaPub resolves @pasturesounds@funkwhale.test" || ko "PrivaPub cannot resolve the channel" +# a run before this one left the follow: unfollow first, so the follow below is a new request +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$channel_on_p/unfollow"; sleep 3 +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$channel_on_p/follow" +# (Funkwhale names its Accept after our Follow, `/accept`, which PrivaPub takes since 2026-10-05) +until_true 45 '[ "$(curl -s -H "$PH" "$P/api/v1/accounts/relationships?id[]=$channel_on_p" | j "print(d[0][\"following\"])")" = "True" ]' \ + && ok "alice_funkwhale follows the channel (Accept arrived)" || ko "the channel's Accept never arrived" + +echo " tracks" +run=$(date +%s); title="A pasture tone $run" +podman exec pasture-funkwhale-api ffmpeg -loglevel error -y -f lavfi -i "sine=frequency=$((300 + run % 500)):duration=4" -c:a libvorbis \ + -metadata title="$title" -metadata artist="Pasture sounds" "/tmp/tone-$run.ogg" +podman cp "pasture-funkwhale-api:/tmp/tone-$run.ogg" "$here/.state/funkwhale-tone.ogg" +upload=$(fw -X POST "$FWB/api/v2/uploads/" -F "audio_file=@$here/.state/funkwhale-tone.ogg" -F "channel=$channel" -F import_status=pending \ + -F "import_metadata={\"title\":\"$title\"}" | j "print(d['uuid'])") +[ -n "$upload" ] && ok "fwuser uploads a track to the channel" || ko "the upload was refused" +until_true 60 '[ -n "$(p_home_has "$title")" ]' && ok "the track reaches alice_funkwhale's home" || ko "the track never arrived" +track_on_p=$(p_home_has "$title") +track=$(curl -s -H "$PH" "$P/api/v1/statuses/$track_on_p") +[ "$(echo "$track" | j "p = d.get('privapub') or {}; print(p.get('object_type'))")" = "Audio" ] && ok "it arrives as Audio" || ko "the track is not Audio" +echo "$track" | j "a = (d.get('privapub') or {}).get('audio') or {}; print(bool(a.get('url') or a.get('variants')) and (a.get('duration') or 0) > 3)" | grep -q True \ + && ok "with its file and its duration" || ko "the track's file or duration is missing ($(echo "$track" | j "print((d.get('privapub') or {}).get('audio'))"))" + +echo " deletes" +fw -o /dev/null -X DELETE "$FWB/api/v2/uploads/$upload/" +until_true 60 '[ "$(curl -s -o /dev/null -w "%{http_code}" -H "$PH" "$P/api/v1/statuses/$track_on_p")" = "404" ]' \ + && ok "the deleted track leaves PrivaPub" || ko "the deleted track stays on PrivaPub" + +echo " unfollow" +curl -s -o /dev/null -X POST -H "$PH" "$P/api/v1/accounts/$channel_on_p/unfollow" +until_true 45 '[ "$(fw_sql "select count(*) from federation_follow f join federation_actor a on a.id = f.actor_id where a.fid like '"'%alice_funkwhale%'"'")" = "0" ]' \ + && ok "alice_funkwhale's unfollow reaches the channel" || ko "the channel still counts alice_funkwhale" + +echo " statistics" +stats_check funkwhale.test funkwhale