From e04d35b131a87ae5291ac7c42e501e589310f0c8 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Thu, 30 Jul 2026 23:18:36 +0000 Subject: [PATCH] feat: recorded-episode pricing, Cashu-token payment, podcast editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docker-compose.yml | 8 +- frontend/src/components/EpisodeSources.vue | 38 +++++++ frontend/src/components/PodcastForm.vue | 45 +++++--- frontend/src/router.ts | 1 + frontend/src/views/HomeView.vue | 5 +- frontend/src/views/PodcastSettingsView.vue | 44 ++++++++ frontend/src/views/StreamDashboard.vue | 20 ++-- mediamtx/mediamtx.yml | 2 +- server/src/routes/marketplace.test.ts | 100 +++++++++++++++++- server/src/routes/marketplace.ts | 114 ++++++++++++++++++++- server/src/routes/streams.ts | 7 +- server/src/services/cashu.ts | 55 ++++++++-- 12 files changed, 401 insertions(+), 38 deletions(-) create mode 100644 frontend/src/views/PodcastSettingsView.vue diff --git a/docker-compose.yml b/docker-compose.yml index da0aa90..34d082b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,13 @@ name: podsteadr services: podsteadr: build: . - container_name: podsteadr + # Container/DNS-alias name deliberately NOT "podsteadr" — on hosts whose own hostname is + # "podsteadr" (e.g. a VPS named after the app), the host's own /etc/hosts self-hostname + # entry (127.0.1.1 podsteadr, added by cloud-init) shadows the container network's DNS + # alias for other containers looking up "podsteadr", so mediamtx's auth-webhook callback + # resolved to the host's loopback instead of this container and every RTMP publish got + # rejected with "connection refused" (observed on podsteadr.atobitcoin.io, 2026-07-30). + container_name: podsteadr-app restart: unless-stopped ports: - "8095:8095" diff --git a/frontend/src/components/EpisodeSources.vue b/frontend/src/components/EpisodeSources.vue index a4ff581..9982332 100644 --- a/frontend/src/components/EpisodeSources.vue +++ b/frontend/src/components/EpisodeSources.vue @@ -37,6 +37,9 @@ const confirming = ref(false); const confirmError = ref(''); const purchased = ref(false); +const tokenInput = ref(''); +const redeemingToken = ref(false); + const sortedRows = computed(() => [...rows.value].sort((a, b) => { if (sortKey.value === 'latencyMs') return (a.latencyMs ?? Infinity) - (b.latencyMs ?? Infinity); @@ -123,10 +126,28 @@ async function checkPaid() { } } +async function payWithToken(row: Row) { + confirmError.value = ''; + redeemingToken.value = true; + try { + await api.post(`/api/podcasts/${props.podcastId}/episodes/${props.episodeId}/purchase/token`, { + source: row.type === 'producer' ? 'producer' : row.pubkey, + token: tokenInput.value.trim(), + }); + purchased.value = true; + emit('purchased'); + } catch (err) { + confirmError.value = (err as Error).message; + } finally { + redeemingToken.value = false; + } +} + function cancelBuy() { buyingPubkey.value = null; invoice.value = ''; quoteId.value = ''; + tokenInput.value = ''; confirmError.value = ''; } @@ -172,6 +193,23 @@ function cancelBuy() { {{ confirming ? 'Checking…' : "I've paid — unlock" }} + +
+

— or pay with a Cashu token instead —

+