Repository navigation
fix(feeds): show every podcast and episode, and keep feed lists correct while they load - #399
Conversation
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.
Deploying podnotes with
|
| Latest commit: |
2b28a78
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://15733de7.podnotes.pages.dev |
| Branch Preview URL: | https://feat-feed-repository.podnotes.pages.dev |
| }; | ||
|
|
||
| const feedEpisodes = Object.values(get(episodeCache)); | ||
| const feedEpisodes = Array.from(get(get(plugin).feeds).values(), ({ episodes }) => episodes); |
There was a problem hiding this comment.
🟡 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.
| 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); |
There was a problem hiding this comment.
🟡 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.
| 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.
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:TypeErroris gone.obsidian://podnoteslinks 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.How it works:
src/services/FeedRepository.tsfetches, parses and caches feeds for the podcast view, Latest Episodes, the URI handler, the settings preview, OPML import and feed notes.savedFeedskey. A feed URL, or a private feed's placeholder, only shares requests and cached documents. Each subscription gets its own episode projection.await.guidandpodcast:transcriptlinks in the repository's documents and cache.episodeMetadata(key, episode)reads them for later note and link work.Episodeobjects anddata.jsonare unchanged.FeedParser,feedDocumentSource.ts,FeedCacheService.ts, theepisodeCachestore and the incremental Latest Episodes merge.Verification
Every check ran on Node 22.23.2 at the PR head:
npm run lintnpm run format:checknpm run typechecknpm run buildnpm run sizenpm run testnpm run docs:buildnpm run test:e2eAn earlier e2e run failed once with "File explorer is not open" in
local-files-runtime, a known harness leak:core-plugins.jsonkeeps"file-explorer": trueafterjourney-links-localreloads the window. The run at the PR head passed 58 of 58 without intervention.The e2e suite:
b4b8faf, which only cleared old caches when a feed first loaded.Obsidian runtime verification
Setup:
http://127.0.0.1.nip.io:27102/<feed>/feed.xml:58a4f64and this branch.TypeError: Cannot read properties of null (reading 'title'), only Alpha listedFor each scenario I also checked state directly:
savedFeedsindata.json.podnotes:feed-cache:v6entry.dev:errors, which reported "No errors captured." on this branch.A
podnoteslink 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.podnotes:feed-cache:v5tov6. 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.vite.config.ts). After an in-place plugin update, changed styles apply without restarting Obsidian.origin/masteraaed353.Note
Fix feed display: show all podcasts and episodes with correct lists during loads
Rebuilds feed loading and display around a new
FeedRepositorythat tracks per-URL feed documents and per-subscription state, replacing the component-local parser, episode cache, and fetch logic inPodcastView.pubDateare now valid; each feed keeps up to the configured limit of episodes, ordered newest first, in the Latest Episodes view.FeedKeyinstead of URL, so two saved podcasts sharing one feed URL remain separate tiles, and removal deletes the correct entry.keyRowsin EpisodeList.svelte, so each duplicate renders as its own row. Podcast cards show initials when artwork is missing or fails.FeedRepositoryin FeedRepository.ts removes the v5 feed cache and v1–v4localStorageentries;parseFeedandparseEpisodeItemsare removed fromFeedDocumentParserandImageno longer supports aninteractiveprop or click events.Macroscope summarized 2b28a78.