2026-07-25 01:11:42 +00:00
|
|
|
# podsteadr — project status & handoff
|
2026-07-10 19:39:22 +00:00
|
|
|
|
|
|
|
|
_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
|
|
|
|
|
|
|
|
|
|
```sh
|
2026-07-25 01:11:42 +00:00
|
|
|
cd ~/podsteadr
|
2026-07-10 19:39:22 +00:00
|
|
|
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`.
|
|
|
|
|
|
2026-07-25 01:11:42 +00:00
|
|
|
State that persists in the named volumes: `podsteadr-data` (SQLite DB, the
|
2026-07-10 19:39:22 +00:00
|
|
|
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
|
2026-07-25 01:11:42 +00:00
|
|
|
identity's secret key is in `/tmp/podsteadr-e2e-key` (hex). For real use you
|
2026-07-10 19:39:22 +00:00
|
|
|
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 |
|
|
|
|
|
|---|---|---|---|
|
2026-07-25 01:11:42 +00:00
|
|
|
| `podsteadr` | built from `Dockerfile` (node:22 + ffmpeg) | 8095 | Fastify API + built Vue UI + RSS feeds |
|
|
|
|
|
| `podsteadr-mediamtx` | `bluenviron/mediamtx:1.19.2` | 1935 (RTMP), 8889 (WHIP), 8189/udp (ICE), 8890→8888 (HLS) | ingest + HLS output + recording |
|
|
|
|
|
| `podsteadr-blossom` | `ghcr.io/hzrd149/blossom-server:4` (4.4.1) | 8098→3000 | sha256-addressed media blobs |
|
2026-07-10 19:39:22 +00:00
|
|
|
|
|
|
|
|
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
|
2026-07-25 01:11:42 +00:00
|
|
|
transits podsteadr), then registers `{sha256, size, …}` with
|
2026-07-10 19:39:22 +00:00
|
|
|
`POST /api/podcasts/:id/episodes`; server HEAD-verifies the blob before
|
|
|
|
|
accepting. Enclosure URL = `<blossom>/<sha256>.mp4`.
|
|
|
|
|
- **RSS** — `GET /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).
|
|
|
|
|
- **Streaming** — `POST /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.
|
2026-07-25 01:11:42 +00:00
|
|
|
- **Status / NIP-53** — podsteadr polls the MediaMTX API (`:9997/v3/paths/list`,
|
2026-07-10 19:39:22 +00:00
|
|
|
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;
|
2026-07-25 01:11:42 +00:00
|
|
|
podsteadr mounts it read-only, lists per-stream files, and
|
2026-07-10 19:39:22 +00:00
|
|
|
`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 test` — **35/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**.
|
2026-07-25 01:11:42 +00:00
|
|
|
- Live leg (manual, via ffmpeg in the podsteadr container):
|
2026-07-10 19:39:22 +00:00
|
|
|
- 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
|
2026-07-25 01:11:42 +00:00
|
|
|
the podsteadr container that address doesn't exist. `BLOSSOM_URL_INTERNAL`
|
2026-07-10 19:39:22 +00:00
|
|
|
(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 shell** — `runOnReady`-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
|
|
|
|
|
|
2026-08-07 14:57:57 +00:00
|
|
|
- **Archipelago packaging — done (2026-08-07)**, on the archy repo branch
|
|
|
|
|
`feat/podsteadr-app-package` (not yet merged/pushed): `apps/podsteadr/manifest.yml`
|
|
|
|
|
(`container.build` from this repo's own Dockerfile, following the
|
|
|
|
|
`apps/indeedhub` externally-sourced-app pattern) + `apps/podsteadr-mediamtx` +
|
|
|
|
|
`apps/podsteadr-blossom`, all three on a dedicated `podsteadr-net` bridge
|
|
|
|
|
network per the `apps/indeedhub-*` sibling-manifest pattern. Bind volumes
|
|
|
|
|
under `/var/lib/archipelago/<app>`. Ports **8095, 1935, 8889, 8189/udp, 8890,
|
|
|
|
|
8098** added to `apps/PORTS.md` (chosen 2026-07-10 to avoid fleet collisions —
|
|
|
|
|
8888 is searxng, hence HLS on 8890), all declared `auth: none` with a
|
|
|
|
|
rationale (public podcast/livestream server — RSS/HLS/blob reads must stay
|
|
|
|
|
reachable with no Archipelago session; podsteadr already gates its own
|
|
|
|
|
sensitive routes via NIP-98). `interfaces.main` → port 8095. Passes
|
|
|
|
|
`scripts/validate-app-manifest.sh` and `cargo test -p archipelago-container
|
|
|
|
|
manifest` in archy. Not yet verified against a real node install — the
|
|
|
|
|
`data_uid`/capabilities guesses for blossom and mediamtx (both root-running
|
|
|
|
|
images writing to fresh bind mounts) are flagged inline as unverified.
|
|
|
|
|
- Separately, podsteadr is also registered in archy's neode-ui dashboard as an
|
|
|
|
|
*external* identity-aware app (bookmark to the standalone
|
|
|
|
|
podsteadr.atobitcoin.io instance + NIP-07 bridge), on archy branch
|
|
|
|
|
`feat/podsteadr-external-nostr-identity` — a lighter integration than the
|
|
|
|
|
installable package above, for the already-hosted instance. The two are
|
|
|
|
|
complementary, not overlapping.
|
|
|
|
|
- Git remote: `http://146.59.87.168:3000/ssmithx/podsteadr.git` (gitea).
|
2026-07-10 19:39:22 +00:00
|
|
|
- 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.
|