feat: recorded-episode pricing, Cashu-token payment, podcast editing

- 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>
This commit is contained in:
2026-07-30 23:18:36 +00:00
co-authored by Claude Sonnet 5
parent a050e2e5f9
commit e04d35b131
12 changed files with 401 additions and 38 deletions
@@ -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 = '';
}
</script>
@@ -172,6 +193,23 @@ function cancelBuy() {
{{ confirming ? 'Checking' : "I've paid unlock" }}
</button>
</div>
<div class="space-y-2 border-t border-white/10 pt-3">
<p class="text-xs text-white/50"> or pay with a Cashu token instead </p>
<textarea
v-model="tokenInput"
class="input font-mono text-xs"
rows="2"
placeholder="cashuB…"
/>
<button
class="btn-secondary w-full"
:disabled="redeemingToken || !tokenInput.trim()"
@click="payWithToken(row)"
>
{{ redeemingToken ? 'Redeeming…' : 'Redeem token — unlock' }}
</button>
</div>
</template>
<p v-else class="text-sm font-medium text-green-400">🎉 Purchased you now have access.</p>
</div>
+31 -14
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
// Wizard questions for creating (or editing) a podcast, including the
// Wizard questions for creating a podcast, or editing an existing one — including the
// lightning address used for the RSS value-for-value block.
import { reactive, ref } from 'vue';
import { api } from '../lib/api';
@@ -15,17 +15,25 @@ export interface PodcastPayload {
lightning_address?: string | null;
}
const emit = defineEmits<{ created: [podcast: { id: string; title: string; feed_url: string }] }>();
const props = defineProps<{
// Pass an existing podcast to edit it in place instead of creating a new one.
podcast?: PodcastPayload & { id: string };
}>();
const emit = defineEmits<{
created: [podcast: { id: string; title: string; feed_url: string }];
saved: [podcast: { id: string; title: string; feed_url: string }];
}>();
const form = reactive<PodcastPayload>({
title: '',
description: '',
author: '',
image_url: '',
language: 'en',
category: 'Technology',
explicit: false,
lightning_address: '',
title: props.podcast?.title ?? '',
description: props.podcast?.description ?? '',
author: props.podcast?.author ?? '',
image_url: props.podcast?.image_url ?? '',
language: props.podcast?.language ?? 'en',
category: props.podcast?.category ?? 'Technology',
explicit: props.podcast?.explicit ?? false,
lightning_address: props.podcast?.lightning_address ?? '',
});
const error = ref('');
const busy = ref(false);
@@ -40,12 +48,21 @@ async function submit() {
error.value = '';
busy.value = true;
try {
const created = await api.post<{ id: string; title: string; feed_url: string }>('/api/podcasts', {
const payload = {
...form,
image_url: form.image_url || null,
lightning_address: form.lightning_address || null,
});
emit('created', created);
};
if (props.podcast) {
const updated = await api.put<{ id: string; title: string; feed_url: string }>(
`/api/podcasts/${props.podcast.id}`,
payload,
);
emit('saved', updated);
} else {
const created = await api.post<{ id: string; title: string; feed_url: string }>('/api/podcasts', payload);
emit('created', created);
}
} catch (err) {
error.value = (err as Error).message;
} finally {
@@ -103,7 +120,7 @@ async function submit() {
<p v-if="error" class="rounded-lg bg-red-500/20 border border-red-500/40 p-3 text-sm text-red-200">{{ error }}</p>
<button class="btn-primary" type="submit" :disabled="busy || !form.title">
{{ busy ? 'Creating…' : 'Create podcast' }}
{{ busy ? (podcast ? 'Saving…' : 'Creating…') : (podcast ? 'Save changes' : 'Create podcast') }}
</button>
</form>
</template>
+1
View File
@@ -9,6 +9,7 @@ export const router = createRouter({
{ path: '/upload', component: () => import('./views/wizard/EpisodeWizard.vue') },
{ path: '/live', component: () => import('./views/wizard/LiveWizard.vue') },
{ path: '/streams/:id', component: () => import('./views/StreamDashboard.vue') },
{ path: '/podcasts/:id/settings', component: () => import('./views/PodcastSettingsView.vue') },
{ path: '/podcasts/:id/episodes/:eid', component: () => import('./views/EpisodeDetailView.vue') },
{ path: '/earnings', component: () => import('./views/EarningsView.vue') },
{ path: '/settings', component: () => import('./views/SettingsView.vue') },
+4 -1
View File
@@ -66,7 +66,10 @@ onMounted(async () => {
<ul class="space-y-2">
<li v-for="p in podcasts" :key="p.id" class="card flex items-center justify-between !p-4">
<span class="font-medium">{{ p.title }}</span>
<a :href="p.feed_url" target="_blank" rel="noopener" class="text-sm text-orange-400 underline">RSS feed</a>
<div class="flex items-center gap-4">
<a :href="p.feed_url" target="_blank" rel="noopener" class="text-sm text-orange-400 underline">RSS feed</a>
<RouterLink :to="`/podcasts/${p.id}/settings`" class="text-sm text-orange-400 underline">Edit</RouterLink>
</div>
</li>
</ul>
</section>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { api } from '../lib/api';
import PodcastForm, { type PodcastPayload } from '../components/PodcastForm.vue';
const route = useRoute();
const router = useRouter();
const podcastId = route.params.id as string;
const podcast = ref<(PodcastPayload & { id: string }) | null>(null);
const loading = ref(true);
const error = ref('');
const saved = ref(false);
onMounted(async () => {
try {
podcast.value = await api.get<PodcastPayload & { id: string }>(`/api/podcasts/${podcastId}`);
} catch (err) {
error.value = (err as Error).message;
} finally {
loading.value = false;
}
});
function onSaved(): void {
saved.value = true;
setTimeout(() => router.push('/'), 800);
}
</script>
<template>
<div class="mx-auto max-w-2xl space-y-6">
<h1 class="text-2xl font-bold text-white">Podcast settings</h1>
<p v-if="loading" class="text-sm text-white/50">Loading</p>
<p v-else-if="error" class="rounded-lg bg-red-500/20 border border-red-500/40 p-3 text-sm text-red-200">{{ error }}</p>
<div v-else class="card">
<PodcastForm :podcast="podcast!" @saved="onSaved" />
<p v-if="saved" class="mt-4 rounded-lg bg-green-500/20 border border-green-500/40 p-3 text-sm text-green-200">
Saved.
</p>
</div>
</div>
</template>
+14 -6
View File
@@ -95,6 +95,7 @@ async function stopBrowserPublish() {
const recordings = ref<RecordingFile[]>([]);
const podcasts = ref<PodcastSummary[]>([]);
const publishTarget = ref('');
const publishPriceSats = ref<number | null>(null);
const publishingFile = ref('');
const publishedFeed = ref('');
@@ -112,6 +113,7 @@ async function publishRecording(file: string) {
podcast_id: publishTarget.value,
title: `${stream.value?.title ?? 'Live stream'} — recording`,
description: stream.value?.summary ?? '',
price_sats: publishPriceSats.value || undefined,
});
publishedFeed.value = podcasts.value.find((p) => p.id === publishTarget.value)?.feed_url ?? '';
} catch (err) {
@@ -184,12 +186,18 @@ async function publishRecording(file: string) {
<!-- Recordings -->
<div v-if="recordings.length" class="card space-y-4">
<h2 class="font-semibold">Recordings</h2>
<div v-if="podcasts.length">
<label class="label" for="rec-target">Publish to podcast</label>
<select id="rec-target" v-model="publishTarget" class="input">
<option value="" disabled>Choose a podcast</option>
<option v-for="p in podcasts" :key="p.id" :value="p.id">{{ p.title }}</option>
</select>
<div v-if="podcasts.length" class="space-y-3">
<div>
<label class="label" for="rec-target">Publish to podcast</label>
<select id="rec-target" v-model="publishTarget" class="input">
<option value="" disabled>Choose a podcast</option>
<option v-for="p in podcasts" :key="p.id" :value="p.id">{{ p.title }}</option>
</select>
</div>
<div>
<label class="label" for="rec-price">Price in sats (optional leave blank for a free episode)</label>
<input id="rec-price" v-model.number="publishPriceSats" type="number" min="1" step="1" class="input" placeholder="Free" />
</div>
</div>
<p v-else class="text-sm text-white/50">
Create a podcast (via <RouterLink class="text-orange-400 underline" to="/upload">Upload an episode</RouterLink>)