Files
podsteadr/docs/STATUS.md
T

10 KiB

podpuddle — project status & handoff

Last updated: 2026-07-10. State: working, verified end-to-end, parked.

What this is

A self-hosted, docker-based server + web frontend for nostr-native podcasting and livestreaming. A user opens the web page, logs in with nostr only (NIP-07 extension), answers wizard questions, and either uploads an mp4 (published to an RSS 2.0 feed with lightning payment info, media stored on Blossom) or goes live (OBS/RTMP or browser/WHIP through MediaMTX, announced on nostr as a NIP-53 live event, watchable via HLS). Live streams are recorded and can be published as podcast episodes with one click.

Intended to eventually run as an Archipelago app (see "Remaining work"), but is a fully standalone compose stack today.

How to resume

cd ~/podpuddle
docker compose up -d          # data volumes were kept; everything resumes as-is
node scripts/e2e.mjs          # 14 automated checks against the running stack

Open http://localhost:8095 and log in with a NIP-07 extension (Alby, nos2x). For a non-localhost box, edit .env (PUBLIC_HOST=…) and docker compose up -d --build.

State that persists in the named volumes: podpuddle-data (SQLite DB, the server's nostr key in config/server-nostr-key, admin = first pubkey that ever logged in), blossom-data (media blobs), mediamtx-recordings (stream recordings, 7-day retention). docker compose down -v wipes all of it.

Note: the automated e2e run left test data behind (an "E2E Test Show" podcast with 2 episodes, test streams, and the e2e identity as admin). The e2e identity's secret key is in /tmp/podpuddle-e2e-key (hex). For real use you probably want a clean slate: docker compose down -v && docker compose up -d, then log in with your own extension first so your pubkey becomes admin.

Architecture

Three containers on one compose network:

Container Image Host ports Role
podpuddle built from Dockerfile (node:22 + ffmpeg) 8095 Fastify API + built Vue UI + RSS feeds
podpuddle-mediamtx bluenviron/mediamtx:1.19.2 1935 (RTMP), 8889 (WHIP), 8189/udp (ICE), 8890→8888 (HLS) ingest + HLS output + recording
podpuddle-blossom ghcr.io/hzrd149/blossom-server:4 (4.4.1) 8098→3000 sha256-addressed media blobs

Key flows:

  • Login — browser signs a kind-27235 (NIP-98) event via NIP-07 → POST /api/auth/login → server verifies (sig, u/method tags, ±60 s skew, replay-guard table) → httpOnly session cookie. NIP-98 headers also accepted directly on any API route (scripting). First pubkey to log in becomes admin.
  • Episode upload — browser hashes the file (crypto.subtle, ≤2 GB), signs a kind-24242 BUD-02 auth via NIP-07, PUTs directly to blossom (file never transits podpuddle), then registers {sha256, size, …} with POST /api/podcasts/:id/episodes; server HEAD-verifies the blob before accepting. Enclosure URL = <blossom>/<sha256>.mp4.
  • RSSGET /feeds/:podcastId/feed.xml, RSS 2.0 + itunes + podcast namespace: podcast:guid (UUIDv5 per spec, verified against the spec test vector), <podcast:value type="lightning" method="lnaddress"> with the owner's lightning address (falls back to a keysend/node block if only a node pubkey is set; omitted if neither).
  • StreamingPOST /api/streams returns {streamId, streamKey (once), rtmpUrl, whipUrl, hlsUrl}. Path is live/<streamId> (public); the secret travels as ?key= (OBS stream key) or WHIP Bearer, is stored only as a sha256 hash, and never appears in viewer URLs. MediaMTX delegates auth to POST /api/mediamtx/auth (2xx = allow); reads are public.
  • Status / NIP-53 — podpuddle polls the MediaMTX API (:9997/v3/paths/list, every 3 s) and flips planned → live → ended (the stock MediaMTX image has no shell, so runOn* curl hooks are impossible — polling is deliberate). On each transition it (re)publishes the kind-30311 live event signed by the server's own nostr key (generated at first boot, data/config/server-nostr-key), with the user p-tagged as host, to the relay list in Settings. 30311 is replaceable, which is why the server key signs rather than the user's.
  • Recordings — MediaMTX records fmp4 segments to the shared volume; podpuddle mounts it read-only, lists per-stream files, and POST /api/streams/:id/recordings/publish remuxes (ffmpeg -c copy +faststart), uploads to blossom with the server's key (the only server-signed upload path), and creates the episode.
  • External blossom — Settings → Blossom URL is a pure switch because browser uploads are user-signed. Caveat: recording publishing is server-signed, so an external blossom must accept uploads from the server's pubkey (shown in GET /api/settings/public as serverPubkey).

Repo layout

docker-compose.yml            # the run path; .env / .env.example for host config
Dockerfile                    # multi-stage: frontend build → server build → node+ffmpeg runtime
mediamtx/mediamtx.yml         # ingest/auth/HLS/recording config (mounted read-only)
blossom/config.yml            # blossom-server v4 config (mounted read-only)
scripts/e2e.mjs               # automated checks against a running stack
server/                       # Fastify + TypeScript + better-sqlite3 (WAL)
  src/app.ts                  #   app assembly + DI context (config/db/settings/publisher/mediamtx/serverKey)
  src/index.ts                #   bootstrap + MediaMTX status poller wiring
  src/plugins/nostr-auth.ts   #   NIP-98 verify + session decorators
  src/services/               #   nip98, rss, blossom, mediamtx, nostr (30311), streamkeys, ffmpeg, settings
  src/routes/                 #   auth, settings, podcasts (episodes), feeds, streams (recordings), mediamtx (webhook)
  src/db/migrations.ts        #   schema (users, sessions, settings, podcasts, episodes, streams, recordings, auth_events)
frontend/                     # Vue 3 + Vite + Tailwind + Pinia
  src/lib/                    #   nip07 (ext bridge), blossom (BUD-02 upload), whip (WHIP client), api
  src/views/                  #   Login, Home, wizard/{EpisodeWizard,LiveWizard}, StreamDashboard, Settings

Verification record (2026-07-10)

  • cd server && npm test35/35 vitest (NIP-98 skew/replay/URL/method/payload tampering, RSS spec vectors incl. podcast:guid, app integration: login, admin claim, episode blob verification, feed ETag/304, stream key create/verify/rotate, webhook allow/deny paths, settings admin gating).
  • node scripts/e2e.mjs against the compose stack — 14/14.
  • Live leg (manual, via ffmpeg in the podpuddle container):
    • RTMP publish with the right key → HLS index.m3u8 200 (after MediaMTX's ?cookieCheck=1 redirect — use curl -L).
    • Wrong key → connection reset by MediaMTX (webhook denial confirmed live).
    • Poller set starts_at/ended_at correctly (planned→live→ended).
    • Recording remuxed, uploaded to blossom, published as episode: feed grew to 2 items, XML well-formed, duration_secs probed (35 s), enclosure on the public blossom URL.
  • NIP-53 relay publishing was not exercised against public relays (the e2e intentionally blanks the relay list, restored afterwards). To verify: create a stream in the UI, go live, then nak req -k 30311 on one of the configured relays or open the stream in zap.stream.

Gotchas (hard-won, do not rediscover)

  1. blossom-server 4.4.1 config: rules: must be nested under storage: — top-level rules: is silently ignored, the ruleset is empty, and every upload fails 401 "Server dose not accept video/mp4 blobs" (typo theirs). The GitHub master branch is a Deno rewrite with a different schema (storage.rules, BUD-11, range support) — the :4 image is the older node/koa codebase. Rule expiration is time-since-last-access (prune), set to 10 years for podcast media.
  2. No HTTP range support in blossom-server 4.x (Range: requests get 200 + full body). Most podcast apps cope (they download), but Apple-Podcasts-style streaming/seeking is degraded. Options later: upgrade to the Deno rewrite, or front blossom with nginx.
  3. Split-horizon blossom URL: browsers use http://<host>:8098, but inside the podpuddle container that address doesn't exist. BLOSSOM_URL_INTERNAL (compose sets http://blossom:3000) is used server-side whenever the active blossom URL equals the bundled default; feeds always carry the public URL.
  4. Stock MediaMTX image has no shellrunOnReady-style hooks can't run curl. Status is polled from the API instead. If you ever switch to the -ffmpeg image variant, hooks become possible but polling still works.
  5. MediaMTX 1.19 config: hlsAllowOrigins/webrtcAllowOrigins (plural, list) — the singular forms are deprecated. MoQ is disabled (moq: no).
  6. NIP-98 event ids collide if the same key signs identical requests within one second — the replay guard then rejects the second. Clients should add a nonce tag (the frontend's real logins are naturally unique; tests add one).

Remaining work

  • Archipelago packaging (the original deployment target, archy repo): apps/podpuddle/manifest.yml + apps/podpuddle-mediamtx + apps/podpuddle-blossom following the apps/btcpay-server (dependencies) + apps/monero-ui (container.build on /opt/archipelago/docker/...) patterns; bind volumes under /var/lib/archipelago/<app>; add ports 8095, 1935, 8889, 8189/udp, 8890, 8098 to apps/PORTS.md (chosen 2026-07-10 to avoid fleet collisions — 8888 is searxng, hence HLS on 8890). interfaces.main → port 8095.
  • No git remote yet — decide where to push (gitea?).
  • Browser-tested only synthetically: the wizards should get a real pass with an actual NIP-07 extension + OBS (the API surface they call is fully covered by the e2e script, so surprises should be cosmetic).
  • Nice-to-haves parked: user-signed 30311 mode (needs an event-template endpoint — not implemented; the builder to reuse is buildLiveEvent() in server/src/services/nostr.ts), zap/chat integration (NIP-53 relays tag), feed covers uploaded to blossom instead of URL-only, chunked hashing for >2 GB files (hash-wasm), range-request support (see gotcha 2), SSE instead of 4 s polling on the stream dashboard.