Skip to content

feat(search): list 20 results and add podcasts from Apple Podcasts links - #373

Merged
chhoumann merged 24 commits into
masterfrom
feat/search-apple-links
Oct 11, 2026
Merged

chhoumann merged 24 commits into
masterfrom
feat/search-apple-links

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fixes #344.

Podcast search in Settings > PodNotes now lists up to 20 shows instead of 3. A common keyword no longer hides the show the user wants.

The search box also accepts Apple Podcasts show links. PodNotes reads the show id from the link and asks https://itunes.apple.com/lookup?id=<id>&entity=podcast for the RSS feed. The show then appears as one card, and the user adds it as usual. Before this change, a link like https://podcasts.apple.com/us/podcast/huberman-lab/id1545953110 failed with "Could not load feed: Invalid RSS feed".

Supported link shapes:

  • podcasts.apple.com and the legacy itunes.apple.com host
  • with or without the country segment
  • with or without the slug
  • with a query string such as ?uo=4 or ?i=<episode> (an episode link adds its show)
  • the id= form (.../viewPodcast?id=<id>, .../podcast?id=<id>)

If Apple does not know the id, or the show has no public RSS feed, the user sees "No public RSS feed found for this Apple Podcasts link." If the lookup request fails, they see "Could not look up this Apple Podcasts link. Please try again."

Raising the limit to 20 exposed three problems, and this PR fixes all of them:

  • Repeated shows. iTunes sometimes lists one feed under several shows. For example, Planet Money appears three times in the "planet money" results. Cards are keyed by feed URL, so Svelte threw each_key_duplicate and the grid did not update. Search now keeps each feed once, under its best-ranked show.
  • Results from an earlier input. Overlapping requests could overwrite the results for newer input. They could also end "Searching..." too early. The grid now ignores any response that does not belong to the latest input.
  • Namesakes replacing a saved show. Search can now list different shows with the same title, such as NPR's Planet Money and a Spreaker show called Planet Money. Saved feeds are keyed by title, so adding the second silently replaced the saved NPR subscription. PodNotes now refuses that add with "You already follow a podcast called Planet Money from another feed." and leaves the saved feed alone. Adding the same feed again does nothing. Each card now shows the author (the iTunes artist name) on one muted line, so namesakes can be told apart. For private feeds, the comparison uses the resolved URL, and a refused private URL never reaches SecretStorage.

The URL parsing and the lookup are plain TypeScript in src/iTunesAPIConsumer.ts, so the planned view redesign can reuse them unchanged. Both requests go through the existing bounded networkRequest boundary. Search and lookup results are now saved with a string collectionId, which matches PodcastFeed. The API returns a number.

Verification

Tested in Obsidian 1.13.7 on Linux, headless, in this worktree's isolated vault (npm run start:e2e-obsidian, project verify skill), driven through Settings > PodNotes > Search Podcasts.

Before the change (master 1080891):

  • "huberman" lists 3 shows.
    before: 3 results
  • The Apple link shows "Could not load feed: Invalid RSS feed".
    before: Apple link fails

After the change, starting from a fresh vault:

  • "huberman" lists all 13 shows iTunes returns. "history" lists 18: iTunes returned 20, and 2 of them have no feedUrl.
    after: huberman

  • "planet money" lists 10 shows, each feed once. An intermediate build without dedupe threw each_key_duplicate here.
    after: planet money

  • Pasting https://podcasts.apple.com/us/podcast/huberman-lab/id1545953110?i=1000650000000 shows the Huberman Lab card. Clicking Add saves this entry in data.json:
    "Huberman Lab": {"url": "https://feeds.megaphone.fm/hubermanlab", "collectionId": "1545953110", ...}

  • The added podcast opens in the PodNotes view with 448 episodes, which matches Apple's trackCount.
    episodes

  • Pasting https://podcasts.apple.com/us/podcast/apple-news-today/id1473872585 shows "No public RSS feed found for this Apple Podcasts link." That show is Apple-hosted and has no feedUrl.
    no feed

  • obsidian dev:errors reports "No errors captured."

  • Namesake refusal, through trusted mouse clicks:

    1. Search "planet money". Cards show their authors (NPR, fert, Alfiany, ...).
    2. Add NPR's Planet Money (card 1). data.json holds {"url":"https://feeds.npr.org/510289/podcast.xml","collectionId":"290783428","author":"NPR"}.
    3. Click Add on fert's Planet Money (card 8). The notice "You already follow a podcast called Planet Money from another feed." appears, and data.json is unchanged.
    4. Reload the plugin with obsidian:e2e -- --reload. Both the in-memory savedFeeds and data.json still hold the NPR entry.

    Before this fix (head 6189d80), the same clicks replaced the entry with https://www.spreaker.com/show/7445480/episodes/feed and collectionId 6820776004, with no notice:
    before: namesake overwrote NPR

    After the fix:
    after: namesake refused

    screen recording (contact sheet)

  • The Apple link flow still adds Huberman Lab on the final head, now with author Scicomm Media.

  • After merging master, a smoke run on the merged build (plugin 2.20.3) still lists 10 cards for "planet money". Pasting https://podcasts.apple.com/us/podcast/the-daily/id1200361736 shows The Daily card. No errors were captured.

The link-to-add flow is recorded here: screen recording (contact sheet).

Commands, all passing at head f2a760c (master 6a6ae2d merged):

npm run lint          exit 0
npm run format:check  All matched files use the correct format.
npm run typecheck     exit 0
npm run build         built in 553ms
npm run test          svelte-check 0 errors 0 warnings; Test Files 81 passed (81), Tests 1256 passed (1256)
npm run docs:build    Documentation built (run as `uvx --with-requirements docs/requirements.txt --from mkdocs==1.6.1 mkdocs build -f docs/mkdocs.yml -d site`, because mkdocs is not on PATH on this host)

The tests use iTunes JSON recorded from the live API on 2026-10-11, stored in tests/fixtures/itunes/:

  • the Huberman Lab lookup
  • an unknown id
  • Apple News Today, which has no feed
  • an artist id
  • the "apple news" search
  • the "planet money" search, which includes the repeated feeds
  • the "planet money" search trimmed to the two namesakes (NPR and fert)
  • the "huberman lab" search

The URL parser is tested against real link samples, both accepted and rejected. Search-term encoding is tested with "Ça & va + bien". The Apple lookup-failure notice has its own test. The namesake guard is tested for these cases:

  • a refused namesake
  • a title that matches an Object property (toString)
  • a followed private feed shown as saved
  • a private feed whose secret is missing on this device
  • no new SecretStorage writes on refusal

Every new test was confirmed to fail when the defect it names is put back.

The changes went through multi-model review, with Opus 5.5 and GPT 6 Astra as reviewers. The original change had four review rounds. The namesake fix, from an independent verifier's finding, had two more. Fable 5.1 was rate-limited, so Opus 5.5 took the Claude seat. All blocking findings are fixed. One commit, c1d085a, is red on purpose: it adds a test before the fix that makes it pass.

Release impact

There are no settings migrations, API changes or URI changes.

Feeds added from search or an Apple link now store author (the iTunes artist name) and store collectionId as a string. Before, it was a number at runtime even though the type says string. The persistence codec already converts a stored number to a string on load and logs a warning (src/persistence/collectionCodecs.ts). New feeds no longer hit that conversion, and existing saved feeds load as before.

Note

Add Apple Podcasts link lookup and raise iTunes search limit to 20

  • The settings search now parses pasted Apple Podcasts / iTunes show links, looks up the show by ID, and adds its public RSS feed. Unknown IDs, feedless shows, and artist IDs return null with distinct user messages.
  • queryiTunesPodcasts in iTunesAPIConsumer.ts now requests 20 results instead of 3, omits results without a feed URL, de-duplicates by exact feed URL, and includes the artist name as author on result cards in PodcastResultCard.svelte.
  • Search and lookup share a bounded fetch helper (15s timeout, 2 MiB, status 200) and a shared feed mapper that rejects non-string feed URLs.
  • The grid guards asynchronous updates with input tokens so stale responses are ignored, and adding a result with an existing title no longer replaces the saved feed — matching feeds are skipped, different ones show a namesake notice.
  • Saved-state matching in PodcastQueryGrid.svelte now uses a private-feed-aware comparison and treats Object.prototype-named titles (e.g. toString) as ordinary keys.
  • Behavioral Change: search returns up to 20 feeds (previously 3), feed URLs are de-duplicated, and results without feed URLs disappear; check queryiTunesPodcasts consumers if the 3-result cap mattered.

Macroscope summarized 38bcfc1.

Search asked iTunes for 3 shows, so a common keyword hid the wanted one.
Ask for 20, drop shows without a public RSS feed since they cannot be
added, and store collectionId as the string PodcastFeed declares.

Refs #344
Pasting a podcasts.apple.com link was parsed as an RSS feed and failed
with "Could not load feed". Recognize Apple Podcasts show links, resolve
the show id through the iTunes lookup API, and offer the show's RSS feed
as a result card. Shows without a public feed get a clear message.

Fixes #344
iTunes search lists some feeds under several shows: "planet money"
returns the NPR Planet Money feed three times. The result grid keys
cards by feed URL, so Svelte threw each_key_duplicate and rendered
nothing new. Keep the best-ranked show for each feed.

Refs #344
Each keystroke pause starts a search, feed load, or Apple Podcasts
lookup, and they can answer out of order. An older response replaced
newer results, cleared "Searching..." while the newest request was still
pending, and refilled the grid after the field was cleared. Only the
latest input may now change the results, error, notice, or progress.

Refs #344
The parser took the first `id<digits>` path segment, so
podcasts.apple.com/us/podcast/id10/id1545953110 looked up show 10. The
show id is the final path segment in Apple Podcasts links.

Refs #344
The iTunes lookup with entity=podcast on an artist id returns the
artist and that artist's shows, so an artist id resolved to one of its
shows (706424103 gave "Apple Events"). Return only the show whose id
was looked up.

Refs #344
Search is the only caller that can get one feed under several shows.
The shared iTunes fetch now returns every mapped feed, and the show
lookup keeps picking the show whose id it looked up.

Refs #344
The newer search answered with Planet Money, whose feed iTunes lists
three times, so a dedupe regression also failed this race test. Answer
with a single-show response instead.

Refs #344
No test failed when the Apple Podcasts lookup branch lost its
latest-request guards. A lookup that resolves after a newer search now
must leave that search's results, with no alert and no notice.

Refs #344
…unce

The latest-request counter only moves when the debounced handler runs,
so for 300 ms after an input change the previous search still counts as
latest. Its answer fills the grid with results for the old input. This
test fails until the counter moves on every input change.

Refs #344
The latest-request counter moved only when the debounced handler ran,
so for 300 ms after an input change the previous request still counted
as latest. Its response could fill the grid, end "Searching...", or
show a notice for the old input. Count each input change at once and
let the debounced handler use the count it finds.

Refs #344
The debounced handler read the latest-request counter itself, so it was
correct only if every caller bumped the counter first. Count the input
change at the call site and pass it with the value, so the handler
compares the count it was called with.

Refs #344
The newer search in the race test answered with the Huberman Lab lookup
fixture. Record the real iTunes search for "huberman lab", which returns
that one show, so the search is answered by a search response.

Refs #344
…is pending

The lookup used to answer after the newer search had finished, so the
search had already cleared "Searching..." and the lookup's finally guard
went untested. Resolve the lookup first: the grid keeps searching and
shows no notice, then the search's results arrive with no alert.

Refs #344
@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: 38bcfc1
Status: ✅  Deploy successful!
Preview URL: https://f03a1d7a.podnotes.pages.dev
Branch Preview URL: https://feat-search-apple-links.podnotes.pages.dev

View logs

Saved feeds are keyed by title, so adding a search result that shares a
saved podcast's title silently replaced that subscription. Searching
"planet money" lists NPR's Planet Money and a namesake by another
author; adding the second overwrote the NPR feed.

Adding a podcast whose title is already saved now leaves the saved feed
untouched. Re-adding the same feed is a no-op, compared by the saved
feed's resolved URL so a private feed's placeholder still matches its
pasted URL. A different feed is refused with a Notice before its URL is
interned into SecretStorage.
Search can list several shows with the same title, such as NPR's Planet
Money and a namesake by another author. Each result card now names the
show's author in one muted line under the title, taken from the iTunes
artistName or the feed's own author.
A search term with "&", "+" and non-ASCII characters must reach iTunes
as one term next to the podcast filters. A failed Apple Podcasts lookup
must show and announce the retry message.
A search result titled like an Object member, such as "toString", read
as already saved, and adding it threw. Saved feeds are now looked up by
own key only, both for the card's Add/Remove state and when adding.

The card and the add guard now share one predicate, isSavedFeed. A
followed private feed matches both its placeholder and the URL it stands
for, so pasting its private URL shows Remove instead of a dead Add
button, and the saved list still shows Remove.

When a saved private feed's URL is missing on this device, adding a
feed under its title now shows the existing re-add message instead of
claiming a different podcast, and stores nothing.
…cretStorage

A refused private namesake must store none of its URL, so the test
fails if the URL is interned before the guard runs.

The namesake test now clears the Notice spy before the namesake click.
The test Button mock dispatches one click more than once, so without the
clear a notice from the first add could satisfy the assertion.
@chhoumann
chhoumann merged commit 1316312 into master Oct 11, 2026
10 checks passed
@chhoumann
chhoumann deleted the feat/search-apple-links branch October 11, 2026 03:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Podcast search returns only 3 results and cannot add from an Apple Podcasts link

1 participant