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:
@@ -20,6 +20,7 @@ async function logout() {
|
||||
</RouterLink>
|
||||
<nav v-if="auth.pubkey" class="flex items-center gap-4 text-sm">
|
||||
<RouterLink to="/" class="hover:text-puddle-600">Home</RouterLink>
|
||||
<RouterLink to="/earnings" class="hover:text-puddle-600">Earnings</RouterLink>
|
||||
<RouterLink to="/settings" class="hover:text-puddle-600">Settings</RouterLink>
|
||||
<button class="btn-secondary !px-3 !py-1" @click="logout">
|
||||
<span class="max-w-[8rem] truncate font-mono text-xs">{{ auth.displayName || auth.pubkey.slice(0, 8) + '…' }}</span>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
// Lists the producer + any certified resellers for a priced episode, times each URL from the
|
||||
// listener's own browser (server-side latency would be meaningless — it doesn't know the
|
||||
// listener's network path), and drives the buy → invoice → confirm flow.
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import CopyField from './CopyField.vue';
|
||||
|
||||
const props = defineProps<{ podcastId: string; episodeId: string }>();
|
||||
const emit = defineEmits<{ purchased: [] }>();
|
||||
|
||||
interface Source {
|
||||
type: 'producer' | 'reseller';
|
||||
pubkey: string;
|
||||
price_sats: number;
|
||||
url: string;
|
||||
sales_count?: number;
|
||||
}
|
||||
interface SourcesResponse {
|
||||
producer: Source;
|
||||
resellers: Source[];
|
||||
}
|
||||
interface Row extends Source {
|
||||
latencyMs: number | null;
|
||||
hostKind: 'FQDN' | 'IPv4';
|
||||
}
|
||||
|
||||
const sortKey = ref<'price_sats' | 'sales_count' | 'latencyMs'>('price_sats');
|
||||
const rows = ref<Row[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
|
||||
const buyingPubkey = ref<string | null>(null);
|
||||
const invoice = ref('');
|
||||
const quoteId = ref('');
|
||||
const confirming = ref(false);
|
||||
const confirmError = ref('');
|
||||
const purchased = ref(false);
|
||||
|
||||
const sortedRows = computed(() =>
|
||||
[...rows.value].sort((a, b) => {
|
||||
if (sortKey.value === 'latencyMs') return (a.latencyMs ?? Infinity) - (b.latencyMs ?? Infinity);
|
||||
if (sortKey.value === 'sales_count') return (b.sales_count ?? 0) - (a.sales_count ?? 0);
|
||||
return a.price_sats - b.price_sats;
|
||||
}),
|
||||
);
|
||||
|
||||
function isIPv4(hostname: string): boolean {
|
||||
return /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname);
|
||||
}
|
||||
|
||||
async function pingUrl(url: string): Promise<number | null> {
|
||||
const start = performance.now();
|
||||
try {
|
||||
// no-cors: we can't read the response, but the promise still resolves once headers
|
||||
// arrive, which is enough to measure round-trip time to a cross-origin mirror.
|
||||
await fetch(url, { method: 'HEAD', mode: 'no-cors', cache: 'no-store' });
|
||||
return Math.round(performance.now() - start);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const res = await api.get<SourcesResponse>(`/api/podcasts/${props.podcastId}/episodes/${props.episodeId}/sources`);
|
||||
const all: Source[] = [res.producer, ...res.resellers];
|
||||
rows.value = all.map((s) => ({
|
||||
...s,
|
||||
latencyMs: null,
|
||||
hostKind: isIPv4(new URL(s.url).hostname) ? 'IPv4' : 'FQDN',
|
||||
}));
|
||||
// Ping in parallel and fill in as they resolve, rather than blocking the whole list.
|
||||
all.forEach(async (s, i) => {
|
||||
const ms = await pingUrl(s.url);
|
||||
const row = rows.value.find((r) => r.pubkey === s.pubkey && r.type === s.type);
|
||||
if (row) row.latencyMs = ms;
|
||||
void i;
|
||||
});
|
||||
} catch (err) {
|
||||
error.value = (err as Error).message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
async function buy(row: Row) {
|
||||
buyingPubkey.value = row.pubkey;
|
||||
confirmError.value = '';
|
||||
invoice.value = '';
|
||||
try {
|
||||
const res = await api.post<{ quoteId: string; invoice: string; amountSats: number }>(
|
||||
`/api/podcasts/${props.podcastId}/episodes/${props.episodeId}/purchase`,
|
||||
{ source: row.type === 'producer' ? 'producer' : row.pubkey },
|
||||
);
|
||||
quoteId.value = res.quoteId;
|
||||
invoice.value = res.invoice;
|
||||
} catch (err) {
|
||||
confirmError.value = (err as Error).message;
|
||||
buyingPubkey.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPaid() {
|
||||
confirming.value = true;
|
||||
confirmError.value = '';
|
||||
try {
|
||||
await api.post(`/api/podcasts/${props.podcastId}/episodes/${props.episodeId}/purchase/${quoteId.value}/confirm`);
|
||||
purchased.value = true;
|
||||
emit('purchased');
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 402) {
|
||||
confirmError.value = "Not paid yet — pay the invoice above, then try again.";
|
||||
} else {
|
||||
confirmError.value = (err as Error).message;
|
||||
}
|
||||
} finally {
|
||||
confirming.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelBuy() {
|
||||
buyingPubkey.value = null;
|
||||
invoice.value = '';
|
||||
quoteId.value = '';
|
||||
confirmError.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<p v-if="loading" class="text-sm text-slate-500">Loading sources…</p>
|
||||
<p v-else-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</p>
|
||||
<template v-else>
|
||||
<div class="flex gap-3 text-xs text-slate-500">
|
||||
<span>Sort by:</span>
|
||||
<button class="underline" :class="{ 'font-semibold text-slate-800': sortKey === 'price_sats' }" @click="sortKey = 'price_sats'">cheapest</button>
|
||||
<button class="underline" :class="{ 'font-semibold text-slate-800': sortKey === 'sales_count' }" @click="sortKey = 'sales_count'">reputation</button>
|
||||
<button class="underline" :class="{ 'font-semibold text-slate-800': sortKey === 'latencyMs' }" @click="sortKey = 'latencyMs'">fastest</button>
|
||||
</div>
|
||||
<ul class="space-y-2">
|
||||
<li v-for="row in sortedRows" :key="`${row.type}-${row.pubkey}`" class="card !p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium">
|
||||
{{ row.type === 'producer' ? 'Producer' : 'Reseller' }}
|
||||
<span class="font-mono text-xs text-slate-400">{{ row.pubkey.slice(0, 12) }}…</span>
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
{{ row.price_sats }} sats
|
||||
<span v-if="row.type === 'reseller'">· {{ row.sales_count }} sales</span>
|
||||
· {{ row.hostKind }}
|
||||
· <span v-if="row.latencyMs != null">{{ row.latencyMs }}ms</span><span v-else>unreachable</span>
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn-primary shrink-0" :disabled="buyingPubkey === row.pubkey" @click="buy(row)">
|
||||
Buy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="buyingPubkey === row.pubkey" class="mt-3 space-y-2 border-t border-slate-100 pt-3">
|
||||
<template v-if="!purchased">
|
||||
<CopyField label="Pay this invoice with any Lightning wallet" :value="invoice" />
|
||||
<p v-if="confirmError" class="text-sm text-red-700">{{ confirmError }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn-secondary" @click="cancelBuy">Cancel</button>
|
||||
<button class="btn-primary" :disabled="confirming" @click="checkPaid">
|
||||
{{ confirming ? 'Checking…' : "I've paid — unlock" }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="text-sm font-medium text-green-700">🎉 Purchased — you now have access.</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,6 +9,8 @@ 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/episodes/:eid', component: () => import('./views/EpisodeDetailView.vue') },
|
||||
{ path: '/earnings', component: () => import('./views/EarningsView.vue') },
|
||||
{ path: '/settings', component: () => import('./views/SettingsView.vue') },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { api } from '../lib/api';
|
||||
|
||||
interface Earning {
|
||||
id: string;
|
||||
purchase_id: string;
|
||||
amount_sats: number;
|
||||
withdrawn_at: number | null;
|
||||
}
|
||||
|
||||
const unwithdrawnSats = ref(0);
|
||||
const earnings = ref<Earning[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
const lud16 = ref('');
|
||||
const withdrawing = ref(false);
|
||||
const error = ref('');
|
||||
const result = ref<{ paid_sats: number } | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
const res = await api.get<{ unwithdrawn_sats: number; earnings: Earning[] }>('/api/earnings');
|
||||
unwithdrawnSats.value = res.unwithdrawn_sats;
|
||||
earnings.value = res.earnings;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
async function withdraw() {
|
||||
error.value = '';
|
||||
result.value = null;
|
||||
withdrawing.value = true;
|
||||
try {
|
||||
const res = await api.post<{ paid_sats: number; preimage: string | null }>('/api/earnings/withdraw', {
|
||||
lud16: lud16.value || undefined,
|
||||
});
|
||||
result.value = res;
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = (err as Error).message;
|
||||
} finally {
|
||||
withdrawing.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<h1 class="text-2xl font-bold">Earnings</h1>
|
||||
<p class="text-sm text-slate-500">
|
||||
Sats from selling episodes directly, plus your producer cut whenever a certified
|
||||
reseller resells one of your episodes.
|
||||
</p>
|
||||
|
||||
<div v-if="loading" class="text-sm text-slate-500">Loading…</div>
|
||||
<template v-else>
|
||||
<div class="card space-y-4">
|
||||
<p class="text-3xl font-bold">{{ unwithdrawnSats }} <span class="text-base font-normal text-slate-500">sats available</span></p>
|
||||
<div v-if="unwithdrawnSats > 0" class="space-y-2">
|
||||
<label class="label" for="withdraw-lud16">Lightning address to withdraw to</label>
|
||||
<input id="withdraw-lud16" v-model="lud16" class="input" placeholder="you@getalby.com" pattern="[\w.+-]+@[\w.-]+" />
|
||||
<p v-if="error" class="text-sm text-red-700">{{ error }}</p>
|
||||
<p v-if="result" class="text-sm font-medium text-green-700">Paid out {{ result.paid_sats }} sats!</p>
|
||||
<button class="btn-primary" :disabled="withdrawing || !lud16" @click="withdraw">
|
||||
{{ withdrawing ? 'Paying out…' : 'Withdraw' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="earnings.length">
|
||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">History</h3>
|
||||
<ul class="space-y-2">
|
||||
<li v-for="e in earnings" :key="e.id" class="card flex items-center justify-between !p-4">
|
||||
<span class="font-medium">{{ e.amount_sats }} sats</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="e.withdrawn_at ? 'bg-slate-100 text-slate-600' : 'bg-green-100 text-green-700'"
|
||||
>{{ e.withdrawn_at ? 'withdrawn' : 'available' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
// The page a priced episode's RSS <enclosure> now points at (see server/src/services/rss.ts):
|
||||
// shows what's being sold, lets a listener buy it from the producer or a certified reseller,
|
||||
// and lets an existing buyer list themselves as a reseller or grab the file once unlocked.
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { api, ApiError } from '../lib/api';
|
||||
import EpisodeSources from '../components/EpisodeSources.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const podcastId = route.params.id as string;
|
||||
const episodeId = route.params.eid as string;
|
||||
|
||||
const title = ref('');
|
||||
const description = ref('');
|
||||
const podcastTitle = ref('');
|
||||
const priceSats = ref<number | null>(null);
|
||||
const loading = ref(true);
|
||||
const notFoundOrFree = ref(false);
|
||||
|
||||
const downloadUrl = ref<string | null>(null);
|
||||
const checkingAccess = ref(true);
|
||||
|
||||
const resellUrl = ref('');
|
||||
const resellPrice = ref<number | null>(null);
|
||||
const resellBusy = ref(false);
|
||||
const resellError = ref('');
|
||||
const resellDone = ref(false);
|
||||
|
||||
async function loadInfo() {
|
||||
try {
|
||||
const res = await api.get<{
|
||||
episode: { title: string; description: string };
|
||||
podcast: { title: string };
|
||||
producer: { price_sats: number };
|
||||
}>(`/api/podcasts/${podcastId}/episodes/${episodeId}/sources`);
|
||||
title.value = res.episode.title;
|
||||
description.value = res.episode.description;
|
||||
podcastTitle.value = res.podcast.title;
|
||||
priceSats.value = res.producer.price_sats;
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && (err.status === 400 || err.status === 404)) {
|
||||
notFoundOrFree.value = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAccess() {
|
||||
checkingAccess.value = true;
|
||||
try {
|
||||
const res = await api.get<{ url: string }>(`/api/podcasts/${podcastId}/episodes/${episodeId}/download-url`);
|
||||
downloadUrl.value = res.url;
|
||||
} catch {
|
||||
downloadUrl.value = null;
|
||||
} finally {
|
||||
checkingAccess.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadInfo();
|
||||
if (!notFoundOrFree.value) await checkAccess();
|
||||
});
|
||||
|
||||
async function becomeReseller() {
|
||||
resellError.value = '';
|
||||
resellBusy.value = true;
|
||||
try {
|
||||
await api.post(`/api/podcasts/${podcastId}/episodes/${episodeId}/resellers`, {
|
||||
download_url: resellUrl.value,
|
||||
price_sats: resellPrice.value,
|
||||
});
|
||||
resellDone.value = true;
|
||||
} catch (err) {
|
||||
resellError.value = (err as Error).message;
|
||||
} finally {
|
||||
resellBusy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<p v-if="loading" class="text-sm text-slate-500">Loading…</p>
|
||||
<p v-else-if="notFoundOrFree" class="rounded-lg bg-amber-50 p-3 text-sm text-amber-800">
|
||||
This episode isn't available for sale (it may be free, or no longer exists).
|
||||
</p>
|
||||
<template v-else>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500">{{ podcastTitle }}</p>
|
||||
<h1 class="text-2xl font-bold">{{ title }}</h1>
|
||||
<p class="mt-2 whitespace-pre-wrap text-slate-600">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="checkingAccess" class="text-sm text-slate-500">Checking access…</div>
|
||||
|
||||
<div v-else-if="downloadUrl" class="card space-y-4">
|
||||
<h2 class="font-semibold text-green-700">🔓 You have access to this episode</h2>
|
||||
<a :href="downloadUrl" class="btn-primary inline-block" target="_blank" rel="noopener">Download</a>
|
||||
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<h3 class="font-semibold">List your copy for resale</h3>
|
||||
<p class="mb-2 text-xs text-slate-400">
|
||||
Point at a Blossom-compatible host serving this same file — the producer still gets
|
||||
a cut of anything sold through you.
|
||||
</p>
|
||||
<template v-if="!resellDone">
|
||||
<div class="space-y-2">
|
||||
<input v-model="resellUrl" class="input" type="url" placeholder="https://your-blossom-host.example" />
|
||||
<input v-model.number="resellPrice" class="input" type="number" min="1" placeholder="Your asking price (sats)" />
|
||||
<p v-if="resellError" class="text-sm text-red-700">{{ resellError }}</p>
|
||||
<button class="btn-secondary" :disabled="resellBusy || !resellUrl || !resellPrice" @click="becomeReseller">
|
||||
{{ resellBusy ? 'Certifying…' : 'List as reseller' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="text-sm font-medium text-green-700">You're now a certified reseller for this episode.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<h2 class="font-semibold">{{ priceSats }} sats to unlock</h2>
|
||||
<EpisodeSources :podcast-id="podcastId" :episode-id="episodeId" @purchased="checkAccess" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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