feat(episodes): let owners remove episodes from the RSS feed

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 21:14:23 +00:00
co-authored by Claude Sonnet 5
parent 69241a28b1
commit 5ebea55353
6 changed files with 110 additions and 4 deletions
+31
View File
@@ -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', () => {