Episode uploads that looked 'stuck uploading to blossom' were actually
stuck waiting on the browser nostr extension's own approval popup —
confirmed via server/blossom logs showing zero incoming requests for
the reported attempt, meaning the upload never left the browser.
The UI flipped to phase 'uploading' (0%) before the NIP-07 signEvent()
call resolved, so a user who didn't notice the extension's popup saw a
progress bar frozen at 0% with no indication anything needed their
action. Added a 'signing' phase with an explicit message telling them
to check for the popup.
mpegts (moved to earlier for OBS B-frame stability) only supports H264.
Browser (WHIP) publishing sends VP8 or VP9 depending on the machine's
available encoders, which mpegts can't mux at all — confirmed live,
'the MPEG-TS variant of HLS supports H264 video only' crash loop.
lowLatency supports the broader codec set browser publishing actually
needs.
Real risk, not resolved by this alone: lowLatency is what caused the
original OBS B-frame muxer crashes this session already fixed once by
moving to mpegts. If that recurs, the correct fix is two MediaMTX
instances (one per hlsVariant) since it's a global, not per-path,
setting — not flip-flopping between the two.
A real test with the H264-preference fix still negotiated VP8 —
RTCRtpSender.getCapabilities('video') apparently didn't list H264 on
that browser/machine (Chrome's H264 encoder is a separate downloadable
component, not guaranteed present). Confirmed via mediamtx's live path
list: still 'tracks: ["VP8"]' after the fix was deployed.
Now tries every MediaMTX-HLS-supported codec in priority order (H264,
VP9, AV1) instead of only H264, and throws a clear error instead of
silently falling back to VP8 if literally none of them are available —
better than a stream that looks live but can never produce working
HLS.
Confirmed live in mediamtx logs: the HLS muxer for a browser-published
stream gets created then immediately destroyed — 'the stream doesn't
contain any supported codec, which are currently AV1, VP9, H265, H264,
Opus, MPEG-4 Audio, KLV'. Chrome's getUserMedia()/getDisplayMedia()
default video codec for WebRTC is VP8, which isn't in that list. The
WHIP publish itself succeeds (stream correctly shows live, mediamtx's
own API confirms bytes arriving) so this was easy to miss — only
hls/live/<id>/index.m3u8 silently 404s with 'muxer is waiting to be
created' forever.
Fixed with RTCRtpTransceiver.setCodecPreferences(), reordering the
video codec list so H264 is offered first — falls through safely if
unavailable. Matches what OBS already sends over RTMP, so recording and
HLS both stay on the one already-tested codec instead of gaining a
second, broken one.
MTX_WEBRTCADDITIONALHOSTS (the ICE host candidate MediaMTX advertises
for WebRTC/WHIP) was wired to PUBLIC_HOST, the Cloudflare-proxied
domain. The WHIP HTTP handshake (SDP offer/answer through nginx) still
succeeds through Cloudflare, so 'stream from this browser' looks like
it works — but the actual media is a raw UDP path (port 8189) that
Cloudflare never forwards regardless of port, same as the earlier RTMP
issue. The browser ends up trying to send video/audio to Cloudflare's
edge, which drops it, so nothing ever actually arrives.
Split into a dedicated MEDIAMTX_WEBRTC_HOST env var (raw origin IP in
production) instead of reusing PUBLIC_HOST, mirroring how
MEDIAMTX_RTMP_PUBLIC already does this for the same reason. Confirmed
via podman inspect that the previously-deployed container really was
resolving MTX_WEBRTCADDITIONALHOSTS to the Cloudflare-proxied hostname
before this fix.
Released 2026-08-05. Fixes land directly on paths podsteadr uses: HLS
muxer recomputes AAC PTS in MPEG-TS segments (iOS playback precision,
relevant since hlsVariant: mpegts), fixes a goroutine leak during HLS
part rotation, fixes a Chrome WebRTC "packet lost" false-positive and
non-deterministic WebRTC track ordering (WHIP ingest path), and fixes
OBS multitrack RTMP URL parsing. No breaking config changes; the new
opt-in features (native forwarding, MoQ draft support) don't affect
this config (moq: no already).
apps/podsteadr, apps/podsteadr-mediamtx, apps/podsteadr-blossom manifests
now exist on the archy repo's feat/podsteadr-app-package branch, per the
guidelines in archy's docs/app-developer-guide.md. Updates the "Remaining
work" list accordingly and notes the separate, complementary external-app
dashboard bookmark integration on feat/podsteadr-external-nostr-identity.
App.vue fell back to the first 8 hex chars of the pubkey whenever
displayName wasn't set (the common case right after nostr sign-in,
before any kind-0 metadata has been fetched) — indistinguishable
between different identities at a glance, and not a form anyone
recognizes as "their" nostr identity. nostr-tools was already a
frontend dependency; just wasn't used for npub encoding anywhere.
Full hex pubkey is still available via a title tooltip on hover.
Vendors Archipelago's NIP-07 provider shim (neode-ui/public/
nostr-provider.js) and loads it in index.html's <head>. It's a no-op
outside an Archipelago iframe (the shim's own window === window.top
guard), so this is always safe to include.
When podsteadr is opened from the Archipelago dashboard (registered
there as an external identity-aware app — see the companion archy
change on branch feat/podsteadr-external-nostr-identity), the parent
frame lets the user pick one of their node's stored nostr identities
and posts window.nostr signing requests through to it. The shim then
runs the existing NIP-98 flow against our own auth (nip07.ts, auth.ts,
routes/auth.ts) exactly as if a browser extension had signed it —
nothing on the server needed to change.
Configured for our actual auth shape via data-* attrs the shim reads
from its own <script> tag: data-session-url="/api/auth/login" (ours,
not indeedhub's /api/auth/nostr/session), data-session-mode="cookie"
(we set a session cookie via @fastify/cookie rather than returning a
bearer token in JSON — the shim previously only knew the token shape),
data-me-url="/api/auth/me" (skip re-running the handshake if already
signed in), data-health-url="/api/health" (our actual health route).
Not wired through an Archipelago app manifest/hook — podsteadr isn't
an orchestrator-managed package, so this copy of nostr-provider.js
won't auto-update with archy OTA releases. Re-sync by hand from
archy/neode-ui/public/nostr-provider.js if that file changes upstream.
Verified: `npm run build` (vue-tsc + vite) clean, dist/index.html
includes the script tag with all four data-* attrs, dist/
nostr-provider.js present and syntactically valid.
lowLatency's small per-part buffering window has very little tolerance
for B-frame reordering (common in typical OBS encoder presets) — a real
test stream crashed the muxer twice in ~2 minutes with "too many
reordered frames" / "unable to extract DTS", tearing down and rebuilding
HLS output each time (visible to viewers as buffering/stalls).
Standard HLS buffers a full segment before finalizing instead of
near-real-time parts, absorbing that jitter. Confirmed via a live test
stream afterward: zero muxer crashes over several minutes, vs. two in
the first two minutes before.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Editable podcast settings: new /podcasts/:id/settings page, reusing
PodcastForm.vue in an edit mode (PUT instead of POST) since it was
previously create-only with no way to fix a field (e.g. lightning
address) after the fact.
- Recorded episodes can now be priced same as uploads: "Publish
recording" gained an optional price_sats field, wired through the
existing episode paywall machinery. Live streams themselves stay
unpaywalled by design — only the resulting recording can be priced.
- Accept Cashu tokens as an alternative to a Lightning invoice:
POST .../purchase/token redeems a pasted token directly (via the
mint's swap/receive flow) and finalizes the purchase in one step,
no quote/confirm round trip. A token worth more than the price is
treated as a tip (seller gets the full amount); worth less is
rejected. Added a "pay with a Cashu token instead" option next to
the existing invoice flow.
- cashu.ts: fixed payout() always requesting an invoice for the full
held balance with no room for the mint's routing-fee reserve, which
made a balance that exactly matched one sale's price permanently
unwithdrawable (needed slightly more than held to cover the fee).
Now shrinks the request and requotes once if the first quote doesn't
fit.
- docker-compose.yml / mediamtx.yml: renamed the podsteadr container's
DNS alias away from the literal string "podsteadr" — on a host whose
own hostname is "podsteadr", cloud-init's self-hostname /etc/hosts
entry shadowed the container-network alias, so mediamtx's auth
webhook callback resolved to the wrong address and rejected every
RTMP publish attempt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Paid episodes:
- server/services/cashu.ts: self-custodied Cashu wallet against a configured
mint (NUT-04 mint quote -> bolt11 invoice -> mint/store proofs -> LNURL-pay
payout on withdraw). Buyers pay a plain Lightning invoice, no Cashu wallet
needed on their end.
- routes/marketplace.ts: purchase/confirm flow, download-url paywall gate,
reseller certification/revocation, earnings ledger + withdraw.
- services/marketplace.ts: producer + certified-reseller source resolution,
with a naive per-seller sales-count reputation signal.
Discovery:
- services/rss.ts: <podsteadr:source> RSS tag on priced episodes (producer +
resellers, price, sales count, url) so pricing/sources are discoverable
straight from the feed, not just a separate API call. Locked episodes point
their <enclosure> at an info page instead of the raw file.
- routes/feeds.ts: /catalog.opml lists every podcast this instance hosts, for
peer podsteadr servers or any OPML-aware crawler to discover without a
central directory.
Frontend: episode wizard price/reseller controls, sources display, earnings
dashboard.
Also fixes CORS and a container-image reference:
- app.ts: register @fastify/cors, open on the catalog/feed/sources endpoints
(already deliberately public/crawlable) and on purchase/confirm (already
accept stateless NIP-98 header auth for exactly this case). Credentials
stay off, so the session cookie never crosses origins — cookie-authed
admin routes stay same-origin-only. Needed so external clients like
podsteadr-player can browse/buy/play from a different origin.
- docker-compose.yml: fully qualify the mediamtx image reference
(docker.io/bluenviron/mediamtx:1.19.2) — some Podman hosts have no
unqualified-search registry configured and fail to resolve short names.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Renames the repo directory and every podpuddle/PODPUDDLE reference
across code, config, and docs to podsteadr/PODSTEADR (package names,
Docker Compose project/service/volume names, env var names, UI/RSS
strings). Existing Docker volume data (uploaded blobs, mediamtx
recordings, the server's sqlite DB and its nostr identity key) was
migrated to new podsteadr_-prefixed volumes with matching filenames
so it isn't orphaned by the rename.
- blossom-server 4.4.1 expects rules nested under storage: — the top-level
rules list was ignored, leaving an empty ruleset that rejected all uploads
- podpuddle now verifies blobs and uploads recordings via BLOSSOM_URL_INTERNAL
(http://blossom:3000) while feeds keep the browser-facing URL; unreachable
blossom now returns 502 instead of a 500
- mediamtx: fix deprecated allow-origin params, disable MoQ
- scripts/e2e.mjs: 14 API checks (login, replay/URL rejection, upload,
episode, feed, stream keys, mediamtx auth) — all green, plus verified
live: RTMP publish with key → HLS 200, wrong key refused, status
planned→live→ended via poller, recording remuxed + published as episode,
feed XML well-formed with 2 items
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>