fix(feed): stop persisting legacy podcast.episodes

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.
This commit is contained in:
2026-09-06 18:46:42 -04:00
parent 132d2079f7
commit 8a173a5180
3 changed files with 96 additions and 8 deletions

View File

@@ -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 /** Load feeds from config.json, pruning episodes outside the retention
* window (completed downloads always kept). When anything was pruned, the * window (completed downloads always kept). When anything was pruned, the
* pruned list is rewritten to config.json (startup cleanup for legacy * pruned list is rewritten to config.json (startup cleanup for legacy
@@ -93,7 +105,9 @@ export async function loadFeedsFromFile(
try { try {
const cfg = await loadConfig(); const cfg = await loadConfig();
if (!Array.isArray(cfg.feeds)) return []; 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 downloadedIds = await readDownloadedEpisodeIds();
const now = new Date(); const now = new Date();
let prunedAny = false; let prunedAny = false;
@@ -122,7 +136,9 @@ export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void {
(async () => { (async () => {
try { try {
const downloadedIds = await readDownloadedEpisodeIds(); const downloadedIds = await readDownloadedEpisodeIds();
const pruned = feeds.map((f) => ({ const pruned = feeds
.map(stripLegacyPodcastEpisodes)
.map((f) => ({
...f, ...f,
episodes: f.episodes.filter((ep) => episodes: f.episodes.filter((ep) =>
episodeIsPersistable(ep, downloadedIds, new Date(), windowDays), episodeIsPersistable(ep, downloadedIds, new Date(), windowDays),

View File

@@ -84,7 +84,11 @@ export const searchByFeedUrl = async (
try { try {
const xml = await fetchFeedXml(trimmed); const xml = await fetchFeedXml(trimmed);
if (xml === null) return []; 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 [ return [
{ {

View File

@@ -36,6 +36,7 @@ import { whenConfigIdle } from "../src/utils/config";
import { FeedVisibility } from "../src/types/feed"; import { FeedVisibility } from "../src/types/feed";
import type { Feed } from "../src/types/feed"; import type { Feed } from "../src/types/feed";
import type { Episode } from "../src/types/episode"; import type { Episode } from "../src/types/episode";
import type { PodcastWithEpisodes } from "../src/types/podcast";
const configJsonPath = join(configHome, "podtui", "config.json"); const configJsonPath = join(configHome, "podtui", "config.json");
const downloadsJsonPath = join(configHome, "podtui", "downloads.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); 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 ── // ── Save path: retention window applied with completed-download exemption ──
test("saveFeedsToFile prunes over-window episodes but keeps completed downloads", async () => { test("saveFeedsToFile prunes over-window episodes but keeps completed downloads", async () => {