From 42c48e59fbb17f02471fcf10f486ef5ee37c6f34 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 13 Aug 2026 17:46:26 -0400 Subject: [PATCH] feat(feed): stable episode ids derived from guid or enclosure URL --- src/api/rss-parser.ts | 39 +++++++++++++++++- tests/rss-episode-ids.test.ts | 76 +++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 tests/rss-episode-ids.test.ts diff --git a/src/api/rss-parser.ts b/src/api/rss-parser.ts index 5104dc6..d789561 100644 --- a/src/api/rss-parser.ts +++ b/src/api/rss-parser.ts @@ -74,6 +74,43 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => { return undefined } +/** FNV-1a 32-bit hash. Deterministic across processes and Bun versions + * (unlike Bun.hash) — used to derive stable episode ids from audio URLs so + * a feed's episode ids never change between refreshes. */ +const fnv1a = (input: string): number => { + let hash = 0x811c9dc5 + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + return hash >>> 0 +} + +/** + * Stable per-episode identity. The old positional id (`feedUrl#index`) was + * invalidated by ANY feed change: a new episode or a pruned one shifted + * every episode's index, so progress/downloads saved under `feedUrl#5` + * attached to whatever episode now sat at index 5 — new episodes resumed + * minutes in. Identity derives from stable content instead: + * 1. `` — the canonical per-episode identifier (required by Apple + * Podcasts; nearly universal). + * 2. The enclosure URL, hashed to keep the id compact (hosts serve + * permanent per-episode URLs; guids can be absent in hand-rolled feeds). + * 3. Positional index as a last resort: no guid AND no audio URL means + * the episode cannot be played, so nothing persistent keys off it. + */ +const stableEpisodeId = ( + feedUrl: string, + item: string, + audioUrl: string, + index: number, +): string => { + const guid = getTagValue(item, "guid") + if (guid) return `${feedUrl}#guid:${guid}` + if (audioUrl) return `${feedUrl}#url:${fnv1a(audioUrl).toString(36)}` + return `${feedUrl}#${index}` +} + /** Extract the `` blocks from an RSS document. Matches items directly * on the full XML string — scoping to first is a redundant 5MB * regex pass that doubles parse cost with no practical benefit (well-formed @@ -122,7 +159,7 @@ export const parseRSSItem = (item: string, feedUrl: string, index: number): Epis const imageUrl = getAttr(item, "itunes:image", "href") || undefined const ep: Episode = { - id: `${feedUrl}#${index}`, + id: stableEpisodeId(feedUrl, item, audioUrl, index), podcastId: feedUrl, title: epTitle, description: epDescription, diff --git a/tests/rss-episode-ids.test.ts b/tests/rss-episode-ids.test.ts new file mode 100644 index 0000000..99389e6 --- /dev/null +++ b/tests/rss-episode-ids.test.ts @@ -0,0 +1,76 @@ +/** + * Episode id stability regression tests. + * + * The parser used to key episodes by their position in the feed + * (`feedUrl#index`). Any feed change — a new episode published, an old one + * pruned — shifted every episode's id, so saved progress/downloads attached + * to whichever episode happened to occupy that index afterward ("start a new + * episode and it resumes minutes in"). Ids must instead be stable per + * episode: when present, else the enclosure URL, else the index. + */ + +import { describe, expect, test } from "bun:test" +import { getRSSItems, parseRSSItem } from "../src/api/rss-parser" + +const FEED = "https://example.com/feed.xml" + +const item = ( + title: string, + date: string, + audioUrl: string, + guid?: string, +): string => + ` +${title} +${date} +${guid ? `${guid}` : ""} + +` + +const parse = (xml: string): ReturnType[] => + getRSSItems(xml).map((it, i) => parseRSSItem(it, FEED, i)) + +describe("stable episode ids", () => { + test("guid-based ids survive a new episode being prepended", () => { + // Two episodes, newest first. Positional ids would be feedUrl#0 / #1. + const before = parse( + `${item("Ep 2", "2026-08-02", "https://cdn.example.com/e2.mp3", "ep-2")}${item("Ep 1", "2026-08-01", "https://cdn.example.com/e1.mp3", "ep-1")}`, + ) + + // A third, newer episode appears at the top — every index shifts. + const after = parse( + `${item("Ep 3", "2026-08-03", "https://cdn.example.com/e3.mp3", "ep-3")}${item("Ep 2", "2026-08-02", "https://cdn.example.com/e2.mp3", "ep-2")}${item("Ep 1", "2026-08-01", "https://cdn.example.com/e1.mp3", "ep-1")}`, + ) + + // The known episodes keep their ids — only the newcomer differs. + expect(after[1].id).toBe(before[0].id) // Ep 2 + expect(after[2].id).toBe(before[1].id) // Ep 1 + expect(after[0].id).not.toBe(before[0].id) // Ep 3 is new + }) + + test("enclosure-URL fallback ids survive reordering (no guid)", () => { + const a = item("A", "2026-08-02", "https://cdn.example.com/a.mp3") + const b = item("B", "2026-08-01", "https://cdn.example.com/b.mp3") + const first = parse(`${a}${b}`) + const reordered = parse(`${b}${a}`) + + // The same episodes at different indexes still carry their own ids. + expect(reordered[0].id).toBe(first[1].id) // B moved to index 0 + expect(reordered[1].id).toBe(first[0].id) // A moved to index 1 + }) + + test("ids are namespaced per feed", () => { + const xml = `${item("Ep", "2026-08-01", "https://cdn.example.com/e.mp3", "same-guid")}` + const parsed = getRSSItems(xml) + const a = parseRSSItem(parsed[0], "https://a.example/feed.xml", 0) + const b = parseRSSItem(parsed[0], "https://b.example/feed.xml", 0) + expect(a.id).not.toBe(b.id) + }) + + test("id is deterministic across parses of the same item", () => { + const xml = `${item("Ep", "2026-08-01", "https://cdn.example.com/e.mp3")}` + const one = parse(xml) + const two = parse(xml) + expect(one[0].id).toBe(two[0].id) + }) +})