feat: Cashu marketplace for paid episodes, resale, and cross-instance discovery
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>
This commit is contained in:
@@ -23,6 +23,7 @@ const error = ref('');
|
||||
|
||||
const epTitle = ref('');
|
||||
const epDescription = ref('');
|
||||
const epPriceSats = ref<number | null>(null);
|
||||
const uploaded = ref<{ sha256: string; size: number } | null>(null);
|
||||
const durationSecs = ref<number | null>(null);
|
||||
const episodeUrl = ref('');
|
||||
@@ -86,6 +87,7 @@ async function publish() {
|
||||
size: file.value.size,
|
||||
mime: file.value.type || 'video/mp4',
|
||||
duration_secs: durationSecs.value,
|
||||
price_sats: epPriceSats.value || undefined,
|
||||
},
|
||||
);
|
||||
episodeUrl.value = episode.enclosure_url;
|
||||
@@ -145,6 +147,14 @@ async function publish() {
|
||||
<label class="label" for="ep-desc">Show notes</label>
|
||||
<textarea id="ep-desc" v-model="epDescription" class="input" rows="4" placeholder="What happens in this episode?" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="ep-price">Price in sats (optional — leave blank for a free episode)</label>
|
||||
<input id="ep-price" v-model.number="epPriceSats" type="number" min="1" step="1" class="input" placeholder="Free" />
|
||||
<p class="text-xs text-slate-400">
|
||||
Buyers pay this to unlock the download, then get a certified receipt they can
|
||||
re-sell the same file with — you get a cut of resales too (see podcast settings).
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="phase !== 'idle'" class="space-y-2">
|
||||
<div class="h-2 overflow-hidden rounded-full bg-slate-200">
|
||||
<div class="h-full bg-puddle-500 transition-all" :style="{ width: `${progress * 100}%` }" />
|
||||
|
||||
Reference in New Issue
Block a user