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:
@@ -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),
|
||||||
|
|||||||
@@ -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 [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user