Skip to content

fix(feeds): show every podcast and episode, and keep feed lists correct while they load - #399

Merged
chhoumann merged 36 commits into
masterfrom
feat/feed-repository
Oct 11, 2026
Merged

chhoumann merged 36 commits into
masterfrom
feat/feed-repository

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fixes #375, fixes #378, fixes #358.

Feeds now load through one FeedRepository, so every place that shows podcasts sees the same data and a slow response can no longer land in the wrong place. For users:

  • Every saved podcast has a tile in the grid. A podcast with missing or broken artwork shows its initials instead of disappearing (Podcasts with missing or broken artwork disappear from the grid #358).
  • The Episode list tab shows the latest episodes even when the feeds loaded while the grid was open (Episode list tab shows "No episodes found." when feeds load while the podcast grid is open #375).
  • Episodes without a publish date are listed (Playable RSS episodes without pubDate disappear from the episode list #378).
  • Refreshing a podcast and then opening another one, or going back to Latest Episodes, shows the right episodes. The "Updating 1 feed" banner clears, and the TypeError is gone.
  • Removing a podcast removes it from the grid, Latest Episodes and the feed cache at once.
  • Two saved podcasts that share a feed URL each get their own tile and episodes, and Remove removes only the one you chose. Before, the whole grid showed "No saved podcasts."
  • Offline, a podcast keeps showing its last downloaded copy, even when that copy is older than the cache lifetime.
  • obsidian://podnotes links fetch the feed, so they find newly published episodes. When the feed cannot be reached they use the last copy. Of two links opened in quick succession, only the newer one changes the episode.
  • A private feed whose URL is not on this device shows its notice once, when you open it or follow a link to it, instead of on every background load.
  • When saved podcasts share a feed URL, the episode share link uses the one that is playing: its own saved entry first, then one with the same name, then any with that URL. Episodes without an iTunes title are now found on pod.link by their title; before, the share link searched for an empty title.

How it works:

  • src/services/FeedRepository.ts fetches, parses and caches feeds for the podcast view, Latest Episodes, the URI handler, the settings preview, OPML import and feed notes.
  • A subscription is identified by its savedFeeds key. A feed URL, or a private feed's placeholder, only shares requests and cached documents. Each subscription gets its own episode projection.
  • The podcast view derives its lists, loading banner and notices from the repository's state. No handler writes a list after an await.
  • The parser keeps each item's guid and podcast:transcript links in the repository's documents and cache. episodeMetadata(key, episode) reads them for later note and link work. Episode objects and data.json are unchanged.
  • Deleted: FeedParser, feedDocumentSource.ts, FeedCacheService.ts, the episodeCache store and the incremental Latest Episodes merge.

Verification

Every check ran on Node 22.23.2 at the PR head:

Command Result
npm run lint pass
npm run format:check pass, 308 files
npm run typecheck pass
npm run build pass
npm run size 314,601 raw, 99,342 gzip
npm run test svelte-check clean, 90 files, 1,434 tests passed
npm run docs:build 15 pages built
npm run test:e2e 11 files, 58 tests passed

An earlier e2e run failed once with "File explorer is not open" in local-files-runtime, a known harness leak: core-plugins.json keeps "file-explorer": true after journey-links-local reloads the window. The run at the PR head passed 58 of 58 without intervention.

The e2e suite:

Obsidian runtime verification

Setup:

  • Obsidian 1.13.7 on headless Linux, in the isolated worktree vault.
  • Evidence feeds came from a local server at http://127.0.0.1.nip.io:27102/<feed>/feed.xml:
    • Evidence Alpha and Evidence Beta: two episodes each.
    • Evidence Undated: one dated and one undated episode.
    • Evidence Artless: no artwork.
    • Evidence Broken Art: artwork URL returns 404.
  • A delay switch made Alpha's feed answer after 3 to 4 s.
  • The same steps ran against master 58a4f64 and this branch.
Scenario master this branch
#358 grid with 5 saved podcasts 3 tiles every tile, initials for missing and broken artwork
#375 Episode list tab after loading on the grid "No episodes found." latest episodes
#378 feed with an undated episode dated episode only both episodes
Refresh Alpha (slow), then open Beta Beta lists Alpha's episodes, banner stuck, screen recording Beta's episodes, banner clears, screen recording
Refresh Alpha (slow), then Latest Episodes TypeError: Cannot read properties of null (reading 'title'), only Alpha listed every podcast listed, no runtime errors
Remove Beta in Settings Beta's episodes stay in Latest Episodes gone at once, cache entry evicted
Two saved podcasts with one URL grid shows "No saved podcasts." both tiles (see the #358 image); each opens under its own name; removing one keeps the other
Offline, cache older than 6 h empty grid episodes from the last copy
Podcast header with broken artwork not reachable (no tile) title only, no broken-image glyph

For each scenario I also checked state directly:

  • savedFeeds in data.json.
  • The podnotes:feed-cache:v6 entry.
  • dev:errors, which reported "No errors captured." on this branch.

A podnotes link to a public feed played the linked episode at the requested time. A link to an episode published after the cache filled played it on this branch and showed "Episode not found" on the previous commit. The Obsidian popup about secrets in some screenshots comes from the headless host, not PodNotes.

Design and review: two architect runners (Claude Fable 5.1, GPT-6 Astra) and a cross-judge (GPT-6.1 Sol) settled the design. Four adversarial review rounds by Claude Fable 5.1 and GPT-6 Astra covered the change, with four fix rounds. Independent verification (V-399) then found two more issues, both fixed here: old caches are removed at load again, and the share link uses the playing podcast's own saved entry, also when a key differs from its title. One note stays open as a follow-up: offline, a link to an episode missing from the cached copy says "Could not load the podcast feed" for a private feed and "Episode not found" for a public one. The public path falls back to the cached copy without reporting the failed fetch. On master, a public link said "Could not load the podcast feed" whenever it was offline. Matching the private message would add bytes.

Release impact

  • data.json: no shape or schema change. Episode snapshots carry the same fields as before.
  • Feed cache: the vault-scoped localStorage cache moves from podnotes:feed-cache:v5 to v6. It now stores parsed feed documents, so two podcasts that share a URL can each project their own episodes. PodNotes deletes v5 and the older v1 to v4 keys when it loads, even with its view closed on mobile, so a private feed URL left in an old cache goes at once. Each feed downloads once after updating. The 75-item and 4 MB limits and the TTL setting are unchanged. An expired copy is now kept as an offline fallback instead of being deleted when read.
  • Private feeds: secrets stay in SecretStorage and never reach the cache or logs. The missing-URL notice shows when you open the podcast or follow a link to it, not on background loads.
  • URIs: same parameters. Links now always fetch the feed and fall back to the last copy offline.
  • API: unchanged.
  • Build: Svelte style hashes are now based on content (vite.config.ts). After an in-place plugin update, changed styles apply without restarting Obsidian.
  • Bundle: 314,601 raw (193 bytes smaller) and 99,342 gzip (26 bytes smaller), measured against origin/master aaed353.

Note

Fix feed display: show all podcasts and episodes with correct lists during loads

Rebuilds feed loading and display around a new FeedRepository that tracks per-URL feed documents and per-subscription state, replacing the component-local parser, episode cache, and fetch logic in PodcastView.

  • Episodes without pubDate are now valid; each feed keeps up to the configured limit of episodes, ordered newest first, in the Latest Episodes view.
  • Saved podcasts are keyed by FeedKey instead of URL, so two saved podcasts sharing one feed URL remain separate tiles, and removal deletes the correct entry.
  • Loading fixes: episodes loaded while the grid is open appear in the Episode List tab; late refresh responses no longer replace the list of a podcast opened since the refresh began; expired cached documents still display through a failed refresh; failed loads fall back to downloaded episodes.
  • Duplicate episode rows get unique keys via keyRows in EpisodeList.svelte, so each duplicate renders as its own row. Podcast cards show initials when artwork is missing or fails.
  • Behavioral Change: constructing FeedRepository in FeedRepository.ts removes the v5 feed cache and v1–v4 localStorage entries; parseFeed and parseEpisodeItems are removed from FeedDocumentParser and Image no longer supports an interactive prop or click events.

Macroscope summarized 2b28a78.

Feed fetching, caching and the Latest Episodes merge lived in the podcast
view, a cache service and a store, and every list was copied into the view
after an await. A plugin-owned FeedRepository now fetches, parses, dedupes
and caches feeds and emits one state per saved podcast, keyed by its
savedFeeds key. The view derives every list from that state instead.

For users: the Episode list tab shows episodes that loaded while the grid
was open (#375); a refresh that answers after you moved to another podcast
or back to Latest Episodes no longer shows the wrong feed or leaves the
"Updating" banner stuck; a removed podcast leaves Latest Episodes and the
feed cache at once; and an expired cache is shown while offline instead of
being deleted on read.

The cache moves to podnotes:feed-cache:v6 and stores parsed documents, so
each feed is fetched once after upgrading. Parsed channels no longer carry
the fetched URL, which kept a private feed's secret out of the cache.
…neys

Four journeys drive the feed paths users hit in Obsidian and fail on
master: a refresh that answers after the user opened another podcast or
went back to Latest Episodes; removing a podcast, which must drop it from
the grid, Latest Episodes and the feed cache at once; two saved podcasts
that share one feed URL, each with its own card, episodes and Remove; and
an offline start with a cache older than its TTL, which must still list the
cached episodes.

The fixture server can now delay a feed's responses, and ageFeedCache
backdates the v6 cache entries. The shared-URL and stale-cache journeys
also need a card for a podcast whose artwork cannot load, which a later
commit adds.
A feed item without <pubDate> was dropped, so its episode never appeared
in the podcast's episode list (#378). Only a title and an enclosure are
required now; an undated episode is listed with no date and sorts last in
Latest Episodes.

The parser also reads each item's podcast:transcript links. They stay in
the feed cache with the item's guid and never reach saved episodes, so
data.json does not change. FeedRepository.episodeMetadata returns both for
one of a subscription's episodes, matched by its enclosure, for features
that need them.
…twork

A podcast without artwork, or whose artwork failed to load (a 404, or any
artwork while offline), had no tile in the podcast grid, so it could not be
opened (#358). Its tile is now the same square with the podcast's initials,
and it opens the podcast like any other. Tiles with artwork look as before.

Two saved podcasts that share one feed URL, for example one added from
Apple Podcasts search and one by pasting its RSS URL, blanked the whole
grid. Grid tiles and the settings rows are now keyed by the saved podcast,
so each has its own tile, and Remove in settings removes only that one.

Obsidian updates a plugin without reloading the window, and Svelte skipped
a component's new styles while its old ones were still in the document, so
changed styles applied only after a restart. Style ids now follow the CSS.
…FeedRepository

Pasting a feed URL in settings, importing an OPML file and filling in a
feed note's metadata each fetched feeds through the FeedParser facade, a
second fetch path beside the repository. They now go through
FeedRepository, so a previewed or imported feed is already loaded when it
is saved, and a saved podcast's feed note reads the cached feed instead of
fetching it again.

FeedParser and its document source are gone, along with the parser entry
points only they used. Their still-relevant tests moved to the parser and
repository tests.
Opening two obsidian://podnotes links in quick succession loaded both
feeds, and whichever answered last won, so an older link could replace the
episode the newer link had just opened, or show an error for a link the
user had already moved past. A link now acts after its feed loads only
when no newer link has arrived since.
Grid tiles are now buttons labelled with the podcast title instead of
Image components, so the sync persistence journey waits for
`.podcast-card[aria-label="Synced Feed"]`. No user-facing change; the
suite stops reporting a false failure.
The cached copy of each podcast's feed now follows the saved podcasts
without waiting for the next download:

- A podcast removed while PodNotes was closed leaves the cache when
  PodNotes next reads it.
- A podcast added after previewing it in Settings is cached right away,
  so it shows offline after a restart.
- Previews and links to podcasts you do not follow no longer rewrite the
  cache.
- Caches written by earlier versions are removed even when the feed
  cache is turned off.
- A cache that cannot be saved (storage full) is removed instead of
  stopping the feed from loading.
- A podcast shows as updating whenever its feed is downloaded, including
  downloads started by a podnotes link or a feed note, not only from the
  podcast list.
- A failed download no longer writes the feed URL to the console, since
  a pasted or linked URL can carry a private token.
- A download always finishes its loading state, and a feed that cannot
  be loaded always reports a real error to the settings preview and to
  links.
A podnotes link now downloads its podcast's feed every time, as links did
before the feed cache existed, so a link to an episode published since
the podcast last loaded plays it instead of saying "Episode not found".
When the feed cannot be reached, for example offline, the link plays from
the last copy on this device.

Adds tests for private feed links: the right episode plays, a podcast
this device does not follow and a missing private URL each show one
notice, a failed download says so once, and an older link that finishes
after a newer one changes nothing.
…podcast

The notice that a private feed's URL is not on this device used to come
from every background refresh, and a podnotes link showed it twice. Now
it shows once, when you open or refresh that podcast. Background loads
(Latest Episodes, Played, feed notes) stay quiet, and a link shows only
its own notice.
Two saved podcasts with the same title, or two episodes with the same
title in one feed, gave two list rows the same key, which blanked the
episode list. Rows now keep unique keys, so every episode shows.
…st header

Opening a podcast whose artwork cannot load (offline, or a dead link)
showed a broken-image icon and its alt text above the episodes. The
header now shows just the title, as it does for a podcast without
artwork, and tries again when the artwork URL changes.
… holds

Settings rows for saved podcasts now carry their savedFeeds key, so
Remove deletes that key without searching savedFeeds for the feed
object. Feed notes read the podcast's channel with one repository call
keyed by the feed itself: a pasted or OPML URL is fetched as is, and a
saved private feed resolves its private URL, even when the caller holds
a copy of the saved record.

Previews, OPML imports and feed notes reuse a fresh cached copy of the
feed instead of downloading it again. One method now decides whether a
feed needs fetching, for subscriptions and for single feeds alike.

Also drops two unused inputs: grid tiles send only their key, and Latest
Episodes reads the saved podcasts from the repository state.
The Podcasts page now says that PodNotes keeps the last copy of each
saved podcast's feed while feed caching is on, shows it when the feed
cannot be reached however old it is, and that Clear cache removes the
copies. It also says that a podcast without artwork shows its initials
in the podcast grid.
Typing in the episode search, hiding played episodes or a refresh that
adds an episode no longer rebuilds every row after the change, so
thumbnails of episodes that stay listed do not fade in again. Rows are
keyed by their episode, and only a repeated episode key gets a suffix.
Removing the feed caches of earlier PodNotes versions is now best
effort. When device storage refuses the removal, podcasts still load
and show their episodes instead of the feed load stopping before it
fetches anything.
The podnotes link tests that build a real feed repository, secret
storage and saved feeds now live in URIHandler.feeds.test.ts with their
own setup. Nothing changes for users.
…e not found"

When a private feed's URL is no longer on this device, a podnotes link
still plays an episode from the feed's last copy. When that copy does
not hold the linked episode, the link now says the private feed is not
available on this device instead of "Episode not found".
The feed repository now publishes a missing private feed URL and the
documents it reads from the cache at the point they change, instead of
once more at the end of every load. resolveFeedUrl takes the same
FeedAddress type the repository uses. Nothing changes for users.
The settings podcast grid computed a column class from the number of
results that no markup or style ever read. Nothing changes for users.
The episode list keys its rows inside the each block, and the episode
list header renders its one "Latest Episodes" back button for podcasts
and playlists alike. The rendered list and header stay the same; the
plugin bundle gets smaller.
Reading the cached feeds and removing old cache versions now share one guard, so storage that throws no longer stops feeds from loading. Clear cache no longer repeats the old-cache cleanup that the first load already did.
…es it

A private podnotes link to an episode newer than the cached copy, opened while the feed cannot be reached, now says the feed could not load instead of "Episode not found".
…contains

A repeated row key now ends in a NUL suffix, which no feed text can contain, so a suffixed key never equals another episode's own key and rows stay stable while the list is filtered.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 2b28a78
Status: ✅  Deploy successful!
Preview URL: https://15733de7.podnotes.pages.dev
Branch Preview URL: https://feat-feed-repository.podnotes.pages.dev

View logs

};

const feedEpisodes = Object.values(get(episodeCache));
const feedEpisodes = Array.from(get(get(plugin).feeds).values(), ({ episodes }) => episodes);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium settings/PodNotesSettingsTab.ts:1348

When an unloaded or failed subscription has no episodes, getRandomEpisode() can select its empty array and return fallbackDemoObj even though another subscribed feed has episodes, causing settings previews to show demo instead of a real episode. FeedRepository.emit() includes these empty arrays here; filter them out before choosing a feed.

Suggested change
const feedEpisodes = Array.from(get(get(plugin).feeds).values(), ({ episodes }) => episodes);
const feedEpisodes = Array.from(get(get(plugin).feeds).values(), ({ episodes }) => episodes).filter(({ length }) => length > 0);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/ui/settings/PodNotesSettingsTab.ts around line 1348:

When an unloaded or failed subscription has no episodes, `getRandomEpisode()` can select its empty array and return `fallbackDemoObj` even though another subscribed feed has episodes, causing settings previews to show `demo` instead of a real episode. `FeedRepository.emit()` includes these empty arrays here; filter them out before choosing a feed.

When removing the caches of earlier versions fails, the current cache is still read, so cached podcasts keep showing offline.
Pins that an episode whose title ends like a row suffix keeps its row when same-titled episodes leave the list, and says why the suffix starts with NUL.
Caches written by earlier versions can hold private feed URLs. They are now removed when PodNotes loads, as before this branch, instead of when a feed first loads, which on mobile can wait until the PodNotes view is opened. The current cache is read on its own, so a failed removal never hides cached episodes. Checking whether the saved podcasts' URLs changed is now a plain comparison, which also writes the cache after a reorder.
… feed

When two saved podcasts share a feed URL, the share link now uses the saved podcast that is playing (its URL and name) instead of the first one with that URL. A same-name entry for another feed still loses to an entry with the episode's URL.
The refresh tests now wait one macrotask after the loading banner clears, so a handler that changed the selection after its load would fail them.
…d cache

J-EDGE-13 now asserts on the credential itself rather than on the old cache key being gone, so it passes on master, which removed only the credential-bearing entry, and fails when the entry stays.
…link

A saved podcast's key can differ from its title, so two saved podcasts can share both a URL and a title. The share link now takes, in order: the entry saved under the playing podcast's name when it has the episode's URL, an entry with the episode's URL and name, any entry with the episode's URL, then the name alone. The saved key is only compared with saved entries, so a podcast called "toString" never reads Object.prototype. The pod.link response checks are shorter with the same effect.
The feed cache's newest-75 cut and the Latest Episodes list now use the same newestFirst comparator from dateCodec, instead of two copies of the same rule.
sameFeed.find((feed) => feed === own) ??
sameFeed.find((feed) => feed.title === podcastName) ??
sameFeed[0] ??
feeds.find((feed) => feed === own || feed.title === podcastName);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium src/getUniversalPodcastLink.ts:51

When there is no URL match, this fallback can return an earlier title-matching alias instead of the podcast's own saved entry, causing resolveCollectionId to use the wrong collectionId and generate an incorrect share link. Keep the own-entry lookup separate and before the title match, as in the URL-matched branches.

Suggested change
feeds.find((feed) => feed === own || feed.title === podcastName);
feeds.find((feed) => feed === own) ?? feeds.find((feed) => feed.title === podcastName);
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/getUniversalPodcastLink.ts around line 51:

When there is no URL match, this fallback can return an earlier title-matching alias instead of the podcast's own saved entry, causing `resolveCollectionId` to use the wrong `collectionId` and generate an incorrect share link. Keep the own-entry lookup separate and before the title match, as in the URL-matched branches.

Episodes from feeds without <itunes:title> carry an empty iTunes title, and the share link searched pod.link for that empty title, so it never found the episode. It now falls back to the episode title when the iTunes title is empty.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant