From 5ebea553538c52bc0e29f9f9b26cf7fd45c0bfa3 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Wed, 9 Sep 2026 21:14:23 +0000 Subject: [PATCH] feat(episodes): let owners remove episodes from the RSS feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "unlisted" flag on episodes rather than reusing the existing hard-delete route, since a hard delete cascades to purchases/earnings (ON DELETE CASCADE) and would wipe a producer's sales history and any unwithdrawn earnings for that episode. Unlisting only affects feed.xml output — the episode, its purchases, and reseller listings all stay intact and it can be relisted at any time. Wires up the missing frontend for it too: the podcast settings page had no episode list or management controls at all before this, despite the backend already exposing full episode CRUD. Co-Authored-By: Claude Sonnet 5 --- frontend/src/views/PodcastSettingsView.vue | 64 +++++++++++++++++++++- server/src/app.test.ts | 31 +++++++++++ server/src/db/migrations.ts | 11 ++++ server/src/routes/feeds.ts | 2 +- server/src/routes/podcasts.ts | 5 +- server/src/types.ts | 1 + 6 files changed, 110 insertions(+), 4 deletions(-) diff --git a/frontend/src/views/PodcastSettingsView.vue b/frontend/src/views/PodcastSettingsView.vue index a260cec..87a1dd2 100644 --- a/frontend/src/views/PodcastSettingsView.vue +++ b/frontend/src/views/PodcastSettingsView.vue @@ -4,18 +4,30 @@ import { useRoute, useRouter } from 'vue-router'; import { api } from '../lib/api'; import PodcastForm, { type PodcastPayload } from '../components/PodcastForm.vue'; +interface Episode { + id: string; + title: string; + pub_date: number; + unlisted: number; +} + const route = useRoute(); const router = useRouter(); const podcastId = route.params.id as string; const podcast = ref<(PodcastPayload & { id: string }) | null>(null); +const episodes = ref([]); const loading = ref(true); const error = ref(''); const saved = ref(false); +const episodeError = ref(''); +const busyEpisodeId = ref(''); onMounted(async () => { try { - podcast.value = await api.get(`/api/podcasts/${podcastId}`); + const data = await api.get(`/api/podcasts/${podcastId}`); + podcast.value = data; + episodes.value = data.episodes; } catch (err) { error.value = (err as Error).message; } finally { @@ -27,6 +39,23 @@ function onSaved(): void { saved.value = true; setTimeout(() => router.push('/'), 800); } + +async function toggleListed(ep: Episode): Promise { + const nextUnlisted = !ep.unlisted; + if (nextUnlisted && !confirm(`Remove "${ep.title}" from the RSS feed? It stays in your library and can be added back later.`)) { + return; + } + episodeError.value = ''; + busyEpisodeId.value = ep.id; + try { + const updated = await api.put(`/api/podcasts/${podcastId}/episodes/${ep.id}`, { unlisted: nextUnlisted }); + ep.unlisted = updated.unlisted; + } catch (err) { + episodeError.value = (err as Error).message; + } finally { + busyEpisodeId.value = ''; + } +} diff --git a/server/src/app.test.ts b/server/src/app.test.ts index cd800d7..ade9396 100644 --- a/server/src/app.test.ts +++ b/server/src/app.test.ts @@ -153,6 +153,7 @@ describe('login allowlist', () => { describe('podcasts, episodes, feed', () => { let podcastId: string; + let episodeId: string; const sha = 'c'.repeat(64); it('creates a podcast', async () => { @@ -188,6 +189,7 @@ describe('podcasts, episodes, feed', () => { }); expect(res.statusCode).toBe(201); expect(res.json().enclosure_url).toContain(`${sha}.mp4`); + episodeId = res.json().id; } finally { vi.unstubAllGlobals(); } @@ -229,6 +231,35 @@ describe('podcasts, episodes, feed', () => { }); expect(cached.statusCode).toBe(304); }); + + it('removing an episode from the feed hides it from feed.xml but keeps it in the owner list', async () => { + const unlist = await app.inject({ + method: 'PUT', + url: `/api/podcasts/${podcastId}/episodes/${episodeId}`, + headers: { cookie }, + payload: { unlisted: true }, + }); + expect(unlist.statusCode).toBe(200); + expect(unlist.json().unlisted).toBe(1); + + const feed = await app.inject({ method: 'GET', url: `/feeds/${podcastId}/feed.xml` }); + expect(feed.body).not.toContain(`${sha}.mp4`); + + const owned = await app.inject({ method: 'GET', url: `/api/podcasts/${podcastId}`, headers: { cookie } }); + expect(owned.json().episodes.some((e: { id: string }) => e.id === episodeId)).toBe(true); + + const relist = await app.inject({ + method: 'PUT', + url: `/api/podcasts/${podcastId}/episodes/${episodeId}`, + headers: { cookie }, + payload: { unlisted: false }, + }); + expect(relist.statusCode).toBe(200); + expect(relist.json().unlisted).toBe(0); + + const feedAgain = await app.inject({ method: 'GET', url: `/feeds/${podcastId}/feed.xml` }); + expect(feedAgain.body).toContain(`${sha}.mp4`); + }); }); describe('streams + mediamtx auth webhook', () => { diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 7c1538d..1a4195c 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -148,6 +148,17 @@ CREATE TABLE cashu_proofs ( created_at INTEGER NOT NULL ); CREATE INDEX idx_cashu_proofs_unspent ON cashu_proofs(spent_at); +`, + }, + { + id: 3, + sql: ` +-- Removing an episode from the RSS feed doesn't have to mean deleting it outright: +-- a hard DELETE cascades to purchases/earnings (ON DELETE CASCADE), which would wipe +-- a producer's sales history and any unwithdrawn earnings for that episode. "unlisted" +-- lets the feed simply omit the episode while everything else (purchases, reseller +-- listings, the blob itself) stays intact and reversible. +ALTER TABLE episodes ADD COLUMN unlisted INTEGER NOT NULL DEFAULT 0; `, }, ]; diff --git a/server/src/routes/feeds.ts b/server/src/routes/feeds.ts index deae531..d65875c 100644 --- a/server/src/routes/feeds.ts +++ b/server/src/routes/feeds.ts @@ -8,7 +8,7 @@ export default async function feedRoutes(app: FastifyInstance) { const { db, settings } = app.ctx; const getPodcast = db.prepare('SELECT * FROM podcasts WHERE id = ?'); - const listEpisodes = db.prepare('SELECT * FROM episodes WHERE podcast_id = ? ORDER BY pub_date DESC'); + const listEpisodes = db.prepare('SELECT * FROM episodes WHERE podcast_id = ? AND unlisted = 0 ORDER BY pub_date DESC'); const listAllPodcasts = db.prepare('SELECT * FROM podcasts ORDER BY created_at'); app.get('/feeds/:id/feed.xml', async (req, reply) => { diff --git a/server/src/routes/podcasts.ts b/server/src/routes/podcasts.ts index 202897f..ce7f935 100644 --- a/server/src/routes/podcasts.ts +++ b/server/src/routes/podcasts.ts @@ -35,6 +35,7 @@ const episodeSchema = z.object({ episode_no: z.number().int().positive().nullish(), pub_date: z.number().int().positive().optional(), price_sats: z.number().int().positive().nullish(), + unlisted: z.boolean().optional(), }); export default async function podcastRoutes(app: FastifyInstance) { @@ -168,10 +169,10 @@ export default async function podcastRoutes(app: FastifyInstance) { const d = { ...episode, ...parsed.data }; db.prepare(` UPDATE episodes SET title=?, description=?, duration_secs=?, season=?, episode_no=?, - pub_date=?, price_sats=? + pub_date=?, price_sats=?, unlisted=? WHERE id=? `).run(d.title, d.description, d.duration_secs ?? null, d.season ?? null, - d.episode_no ?? null, d.pub_date, d.price_sats ?? null, eid); + d.episode_no ?? null, d.pub_date, d.price_sats ?? null, d.unlisted ? 1 : 0, eid); return getEpisode.get(eid, id) as Episode; }); diff --git a/server/src/types.ts b/server/src/types.ts index b4ca522..99a9241 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -41,6 +41,7 @@ export interface Episode { price_sats: number | null; pub_date: number; created_at: number; + unlisted: number; } export interface Purchase {