From 8a173a5180d72c25747497042cd8dbab265536d0 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 6 Sep 2026 18:46:42 -0400 Subject: [PATCH] fix(feed): stop persisting legacy podcast.episodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search-time parseRSSFeed once embedded the full episode history inside Feed.podcast (2,100+ stale copies, 3.8 MB of config). Nothing reads it — feed.episodes is the source of truth — so load and save now strip podcast.episodes, and searchByFeedUrl drops them at the parse site. --- src/utils/feeds-persistence.ts | 30 +++++++++++---- src/utils/search.ts | 6 ++- tests/feed-retention.test.ts | 68 ++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/src/utils/feeds-persistence.ts b/src/utils/feeds-persistence.ts index 4908293..5a2ca6f 100644 --- a/src/utils/feeds-persistence.ts +++ b/src/utils/feeds-persistence.ts @@ -83,6 +83,18 @@ function reviveDates(feed: Feed): Feed { })), }; } + +/** Config-legacy baggage: search-time parseRSSFeed once embedded the full + * episode history inside podcast.episodes (2,100+ stale copies, 3.8 MB of + * config). Nothing reads them — feed.episodes is the source of truth — so + * every load/save drops them. */ +function stripLegacyPodcastEpisodes(feed: Feed): Feed { + if (!("episodes" in feed.podcast)) return feed; + const { episodes: _legacy, ...podcast } = feed.podcast; + void _legacy; + return { ...feed, podcast: podcast as Feed["podcast"] }; +} + /** Load feeds from config.json, pruning episodes outside the retention * window (completed downloads always kept). When anything was pruned, the * pruned list is rewritten to config.json (startup cleanup for legacy @@ -93,7 +105,9 @@ export async function loadFeedsFromFile( try { const cfg = await loadConfig(); if (!Array.isArray(cfg.feeds)) return []; - const feeds = cfg.feeds.map(reviveDates); + const feeds = cfg.feeds + .map(reviveDates) + .map(stripLegacyPodcastEpisodes); const downloadedIds = await readDownloadedEpisodeIds(); const now = new Date(); let prunedAny = false; @@ -122,12 +136,14 @@ export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void { (async () => { try { const downloadedIds = await readDownloadedEpisodeIds(); - const pruned = feeds.map((f) => ({ - ...f, - episodes: f.episodes.filter((ep) => - episodeIsPersistable(ep, downloadedIds, new Date(), windowDays), - ), - })); + const pruned = feeds + .map(stripLegacyPodcastEpisodes) + .map((f) => ({ + ...f, + episodes: f.episodes.filter((ep) => + episodeIsPersistable(ep, downloadedIds, new Date(), windowDays), + ), + })); updateConfig({ feeds: pruned }); } catch { updateConfig({ feeds }); /* never lose data on an error path */ diff --git a/src/utils/search.ts b/src/utils/search.ts index 7e8b2b7..3636bd5 100644 --- a/src/utils/search.ts +++ b/src/utils/search.ts @@ -84,7 +84,11 @@ export const searchByFeedUrl = async ( try { const xml = await fetchFeedXml(trimmed); if (xml === null) return []; - const podcast = parseRSSFeed(xml, trimmed); + // Full parse's episodes are dead weight here (2,100+ stale copies were + // previously persisted inside Feed.podcast): addFeed refetches through + // fetchEpisodes and nothing reads Podcast.episodes off a search result. + const { episodes: _episodes, ...podcast } = parseRSSFeed(xml, trimmed); + void _episodes; return [ { diff --git a/tests/feed-retention.test.ts b/tests/feed-retention.test.ts index 0c5d129..3079bb2 100644 --- a/tests/feed-retention.test.ts +++ b/tests/feed-retention.test.ts @@ -36,6 +36,7 @@ import { whenConfigIdle } from "../src/utils/config"; import { FeedVisibility } from "../src/types/feed"; import type { Feed } from "../src/types/feed"; import type { Episode } from "../src/types/episode"; +import type { PodcastWithEpisodes } from "../src/types/podcast"; const configJsonPath = join(configHome, "podtui", "config.json"); const downloadsJsonPath = join(configHome, "podtui", "downloads.json"); @@ -169,6 +170,73 @@ test("DEFAULT_EPISODE_WINDOW_DAYS is 60", () => { expect(DEFAULT_EPISODE_WINDOW_DAYS).toBe(60); }); +// ── Legacy podcast.episodes baggage ──────────────────────────────────────── + +test("saveFeedsToFile strips legacy podcast.episodes from the persisted feed", async () => { + const feed = makeFeed([ + makeEpisode({ id: "recent-id", pubDate: new Date(Date.now() - 5 * DAY) }), + ]); + // Simulate the pre-fix shape: parseRSSFeed's full history embedded on + // the podcast object (841 stale copies were persisted this way). + const podcastWithLegacy = feed.podcast as PodcastWithEpisodes; + podcastWithLegacy.episodes = [ + makeEpisode({ id: "stale-history-1" }), + makeEpisode({ id: "stale-history-2" }), + ]; + + saveFeedsToFile([feed]); + await settleWrites(); + const raw = await Bun.file(configJsonPath).json(); + expect("episodes" in raw.feeds[0].podcast).toBe(false); +}); + +test("loadFeedsFromFile drops legacy podcast.episodes from a seeded config", async () => { + await Bun.write( + configJsonPath, + JSON.stringify({ + feeds: [ + { + id: "feed-1", + podcast: { + id: "feed-1", + title: "Baggage Show", + description: "", + author: "tester", + feedUrl: "https://example.com/baggage.xml", + lastUpdated: new Date().toISOString(), + isSubscribed: true, + episodes: [ + { id: "huge-stale-1", title: "archived copy" }, + { id: "huge-stale-2", title: "archived copy" }, + ], + }, + episodes: [ + { + id: "recent-id", + podcastId: "feed-1", + title: "Recent", + description: "", + audioUrl: "https://example.com/audio/recent.mp3", + duration: 60, + pubDate: new Date().toISOString(), + }, + ], + visibility: "public", + sourceId: "source-1", + lastUpdated: new Date().toISOString(), + isPinned: false, + }, + ], + }), + ); + + const feeds = await loadFeedsFromFile(); + + expect(feeds).toHaveLength(1); + expect(feeds[0].episodes.map((e) => e.id)).toEqual(["recent-id"]); + expect("episodes" in feeds[0].podcast).toBe(false); +}); + // ── Save path: retention window applied with completed-download exemption ── test("saveFeedsToFile prunes over-window episodes but keeps completed downloads", async () => {