From 0bc049ab08d5cdf8301c137adb0f06ac73d77536 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 02:52:31 +0200 Subject: [PATCH 01/22] feat: list up to 20 podcast search results 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 --- src/iTunesAPIConsumer.test.ts | 69 ++++++++----- src/iTunesAPIConsumer.ts | 37 ++++--- tests/fixtures/itunes/search-apple-news.json | 102 +++++++++++++++++++ 3 files changed, 166 insertions(+), 42 deletions(-) create mode 100644 tests/fixtures/itunes/search-apple-news.json diff --git a/src/iTunesAPIConsumer.test.ts b/src/iTunesAPIConsumer.test.ts index ab32a48b..b54770ed 100644 --- a/src/iTunesAPIConsumer.test.ts +++ b/src/iTunesAPIConsumer.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import searchAppleNews from "../tests/fixtures/itunes/search-apple-news.json"; import { fetchJsonWithTimeout, NetworkError } from "./utility/networkRequest"; import { queryiTunesPodcasts } from "./iTunesAPIConsumer"; @@ -9,42 +10,54 @@ vi.mock("./utility/networkRequest", async (importOriginal) => { const fetchJsonMock = vi.mocked(fetchJsonWithTimeout); +function requestedUrl(): URL { + return new URL(fetchJsonMock.mock.calls[0][0]); +} + +beforeEach(() => { + fetchJsonMock.mockReset(); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => vi.restoreAllMocks()); + describe("queryiTunesPodcasts", () => { - beforeEach(() => { - fetchJsonMock.mockReset(); - vi.spyOn(console, "error").mockImplementation(() => {}); - }); + it("asks for 20 shows through a bounded request", async () => { + fetchJsonMock.mockResolvedValue(searchAppleNews); - afterEach(() => vi.restoreAllMocks()); + await queryiTunesPodcasts("apple news"); - it("uses a bounded request and projects search results", async () => { - fetchJsonMock.mockResolvedValue({ - results: [ - { - collectionName: "Example Show", - feedUrl: "https://feeds.example.com/show.xml", - artworkUrl100: "https://images.example.com/show.jpg", - collectionId: "123", - }, - ], + const url = requestedUrl(); + expect(url.origin + url.pathname).toBe("https://itunes.apple.com/search"); + expect(url.searchParams.get("term")).toBe("apple news"); + expect(url.searchParams.get("limit")).toBe("20"); + expect(fetchJsonMock).toHaveBeenCalledWith(expect.any(String), { + timeoutMs: 15_000, + maxResponseBytes: 2 * 1024 * 1024, + acceptedStatuses: [200], }); + }); + + it("maps shows to feeds and drops shows without a public RSS feed", async () => { + // The first result, Apple News Today, is Apple-hosted and has no feedUrl. + fetchJsonMock.mockResolvedValue(searchAppleNews); - await expect(queryiTunesPodcasts("example & show")).resolves.toEqual([ + await expect(queryiTunesPodcasts("apple news")).resolves.toEqual([ { - title: "Example Show", - url: "https://feeds.example.com/show.xml", - artworkUrl: "https://images.example.com/show.jpg", - collectionId: "123", + title: "Apple News In Conversation", + url: "https://apple.news/podcast/apple_news_in_conversation", + artworkUrl: + "https://is1-ssl.mzstatic.com/image/thumb/Podcasts221/v4/21/e5/93/21e59350-3d63-8dfb-f8a1-c762f85e069c/mza_13661945558824532647.jpg/100x100bb.jpg", + collectionId: "1577591053", }, - ]); - expect(fetchJsonMock).toHaveBeenCalledWith( - expect.stringMatching(/^https:\/\/itunes\.apple\.com\/search\?/), { - timeoutMs: 15_000, - maxResponseBytes: 2 * 1024 * 1024, - acceptedStatuses: [200], + title: "Apple Quarterly Earnings Call", + url: "https://rss.art19.com/apple-quarterly-earnings-call", + artworkUrl: + "https://is1-ssl.mzstatic.com/image/thumb/Podcasts211/v4/72/23/72/72237247-8b65-6174-8aa2-0a3691352b02/mza_5761462598177267980.jpeg/100x100bb.jpg", + collectionId: "74942331", }, - ); + ]); }); it("turns an invalid response shape into a redacted boundary error", async () => { @@ -57,7 +70,7 @@ describe("queryiTunesPodcasts", () => { expect((error as NetworkError).code).toBe("invalid-response"); expect(String(error)).not.toContain(marker); expect(console.error).toHaveBeenCalledWith( - "iTunes search failed.", + "iTunes request failed.", "invalid-response", undefined, ); diff --git a/src/iTunesAPIConsumer.ts b/src/iTunesAPIConsumer.ts index 44aa817d..f1a320a2 100644 --- a/src/iTunesAPIConsumer.ts +++ b/src/iTunesAPIConsumer.ts @@ -6,24 +6,27 @@ const MAX_ITUNES_RESPONSE_BYTES = 2 * 1024 * 1024; interface iTunesResult { collectionName: string; - feedUrl: string; + feedUrl?: string; artworkUrl100: string; - collectionId: string; + collectionId: number; } -interface iTunesSearchResponse { +interface iTunesResponse { results: iTunesResult[]; } export async function queryiTunesPodcasts(query: string): Promise { - const url = new URL("https://itunes.apple.com/search?"); + const url = new URL("https://itunes.apple.com/search"); url.searchParams.append("term", query); url.searchParams.append("media", "podcast"); - url.searchParams.append("limit", "3"); + url.searchParams.append("limit", "20"); url.searchParams.append("kind", "podcast"); + return fetchPodcastFeeds(url); +} +async function fetchPodcastFeeds(url: URL): Promise { try { - const data = await fetchJsonWithTimeout(url.href, { + const data = await fetchJsonWithTimeout(url.href, { timeoutMs: ITUNES_REQUEST_TIMEOUT_MS, maxResponseBytes: MAX_ITUNES_RESPONSE_BYTES, acceptedStatuses: [200], @@ -32,21 +35,27 @@ export async function queryiTunesPodcasts(query: string): Promise throw new NetworkError("invalid-response"); } - return data.results.map((d) => ({ - title: d.collectionName, - url: d.feedUrl, - artworkUrl: d.artworkUrl100, - collectionId: d.collectionId, - })); + return data.results.map(toPodcastFeed).filter((feed) => feed !== null); } catch (error) { // Keep diagnostics useful without copying a target, query, native error, or // response body into the console. NetworkError messages and status values are // deliberately redacted by the shared boundary. if (error instanceof NetworkError) { - console.error("iTunes search failed.", error.code, error.status); + console.error("iTunes request failed.", error.code, error.status); throw error; } - console.error("iTunes search response is invalid."); + console.error("iTunes response is invalid."); throw new NetworkError("invalid-response"); } } + +// Shows without a public RSS feed (Apple-hosted or subscription-only) cannot be added. +function toPodcastFeed(result: iTunesResult): PodcastFeed | null { + if (typeof result.feedUrl !== "string") return null; + return { + title: result.collectionName, + url: result.feedUrl, + artworkUrl: result.artworkUrl100, + collectionId: String(result.collectionId), + }; +} diff --git a/tests/fixtures/itunes/search-apple-news.json b/tests/fixtures/itunes/search-apple-news.json new file mode 100644 index 00000000..5374375e --- /dev/null +++ b/tests/fixtures/itunes/search-apple-news.json @@ -0,0 +1,102 @@ +{ + "resultCount": 3, + "results": [ + { + "wrapperType": "track", + "kind": "podcast", + "collectionId": 1473872585, + "trackId": 1473872585, + "artistName": "Apple News", + "collectionName": "Apple News Today", + "trackName": "Apple News Today", + "collectionCensoredName": "Apple News Today", + "trackCensoredName": "Apple News Today", + "collectionViewUrl": "https://podcasts.apple.com/us/podcast/apple-news-today/id1473872585?uo=4", + "trackViewUrl": "https://podcasts.apple.com/us/podcast/apple-news-today/id1473872585?uo=4", + "artworkUrl30": "https://is1-ssl.mzstatic.com/image/thumb/Podcasts211/v4/f2/04/2a/f2042a31-a0c9-9eaa-9823-463b64a12da7/mza_11550077189211837325.jpg/30x30bb.jpg", + "artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Podcasts211/v4/f2/04/2a/f2042a31-a0c9-9eaa-9823-463b64a12da7/mza_11550077189211837325.jpg/60x60bb.jpg", + "artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Podcasts211/v4/f2/04/2a/f2042a31-a0c9-9eaa-9823-463b64a12da7/mza_11550077189211837325.jpg/100x100bb.jpg", + "collectionPrice": 0.0, + "trackPrice": 0.0, + "collectionHdPrice": 0, + "releaseDate": "2026-10-10T11:00:00Z", + "collectionExplicitness": "notExplicit", + "trackExplicitness": "cleaned", + "trackCount": 1669, + "trackTimeMillis": 2040, + "country": "USA", + "currency": "USD", + "primaryGenreName": "Daily News", + "contentAdvisoryRating": "Clean", + "artworkUrl600": "https://is1-ssl.mzstatic.com/image/thumb/Podcasts211/v4/f2/04/2a/f2042a31-a0c9-9eaa-9823-463b64a12da7/mza_11550077189211837325.jpg/600x600bb.jpg", + "genreIds": ["1526", "26", "1489"], + "genres": ["Daily News", "Podcasts", "News"] + }, + { + "wrapperType": "track", + "kind": "podcast", + "collectionId": 1577591053, + "trackId": 1577591053, + "artistName": "Apple News", + "collectionName": "Apple News In Conversation", + "trackName": "Apple News In Conversation", + "collectionCensoredName": "Apple News In Conversation", + "trackCensoredName": "Apple News In Conversation", + "collectionViewUrl": "https://podcasts.apple.com/us/podcast/apple-news-in-conversation/id1577591053?uo=4", + "feedUrl": "https://apple.news/podcast/apple_news_in_conversation", + "trackViewUrl": "https://podcasts.apple.com/us/podcast/apple-news-in-conversation/id1577591053?uo=4", + "artworkUrl30": "https://is1-ssl.mzstatic.com/image/thumb/Podcasts221/v4/21/e5/93/21e59350-3d63-8dfb-f8a1-c762f85e069c/mza_13661945558824532647.jpg/30x30bb.jpg", + "artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Podcasts221/v4/21/e5/93/21e59350-3d63-8dfb-f8a1-c762f85e069c/mza_13661945558824532647.jpg/60x60bb.jpg", + "artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Podcasts221/v4/21/e5/93/21e59350-3d63-8dfb-f8a1-c762f85e069c/mza_13661945558824532647.jpg/100x100bb.jpg", + "collectionPrice": 0.0, + "trackPrice": 0.0, + "collectionHdPrice": 0, + "releaseDate": "2026-10-01T21:00:00Z", + "collectionExplicitness": "notExplicit", + "trackExplicitness": "cleaned", + "trackCount": 259, + "trackTimeMillis": 1601, + "country": "USA", + "currency": "USD", + "primaryGenreName": "News Commentary", + "contentAdvisoryRating": "Clean", + "artworkUrl600": "https://is1-ssl.mzstatic.com/image/thumb/Podcasts221/v4/21/e5/93/21e59350-3d63-8dfb-f8a1-c762f85e069c/mza_13661945558824532647.jpg/600x600bb.jpg", + "genreIds": ["1530", "26", "1489"], + "genres": ["News Commentary", "Podcasts", "News"] + }, + { + "wrapperType": "track", + "kind": "podcast", + "artistId": 706424103, + "collectionId": 74942331, + "trackId": 74942331, + "artistName": "Apple Inc.", + "collectionName": "Apple Quarterly Earnings Call", + "trackName": "Apple Quarterly Earnings Call", + "collectionCensoredName": "Apple Quarterly Earnings Call", + "trackCensoredName": "Apple Quarterly Earnings Call", + "artistViewUrl": "https://podcasts.apple.com/us/artist/apple-inc/706424103?uo=4", + "collectionViewUrl": "https://podcasts.apple.com/us/podcast/apple-quarterly-earnings-call/id74942331?uo=4", + "feedUrl": "https://rss.art19.com/apple-quarterly-earnings-call", + "trackViewUrl": "https://podcasts.apple.com/us/podcast/apple-quarterly-earnings-call/id74942331?uo=4", + "artworkUrl30": "https://is1-ssl.mzstatic.com/image/thumb/Podcasts211/v4/72/23/72/72237247-8b65-6174-8aa2-0a3691352b02/mza_5761462598177267980.jpeg/30x30bb.jpg", + "artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Podcasts211/v4/72/23/72/72237247-8b65-6174-8aa2-0a3691352b02/mza_5761462598177267980.jpeg/60x60bb.jpg", + "artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Podcasts211/v4/72/23/72/72237247-8b65-6174-8aa2-0a3691352b02/mza_5761462598177267980.jpeg/100x100bb.jpg", + "collectionPrice": 0.0, + "trackPrice": 0.0, + "collectionHdPrice": 0, + "releaseDate": "2026-08-14T00:00:00Z", + "collectionExplicitness": "notExplicit", + "trackExplicitness": "cleaned", + "trackCount": 1, + "trackTimeMillis": 16, + "country": "USA", + "currency": "USD", + "primaryGenreName": "Tech News", + "contentAdvisoryRating": "Clean", + "artworkUrl600": "https://is1-ssl.mzstatic.com/image/thumb/Podcasts211/v4/72/23/72/72237247-8b65-6174-8aa2-0a3691352b02/mza_5761462598177267980.jpeg/600x600bb.jpg", + "genreIds": ["1528", "26", "1489", "1490"], + "genres": ["Tech News", "Podcasts", "News", "Business News"] + } + ] +} From 03def58e90977f2b9030f0ce057c0354475f881a Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 02:55:42 +0200 Subject: [PATCH 02/22] feat: add podcasts from Apple Podcasts show links 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 --- src/iTunesAPIConsumer.test.ts | 90 ++++++++++++++++++- src/iTunesAPIConsumer.ts | 34 +++++++ src/ui/settings/PodcastQueryGrid.svelte | 27 +++++- src/ui/settings/PodcastQueryGrid.test.ts | 57 ++++++++++++ .../itunes/lookup-apple-news-today.json | 36 ++++++++ .../fixtures/itunes/lookup-huberman-lab.json | 37 ++++++++ tests/fixtures/itunes/lookup-unknown-id.json | 4 + 7 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 src/ui/settings/PodcastQueryGrid.test.ts create mode 100644 tests/fixtures/itunes/lookup-apple-news-today.json create mode 100644 tests/fixtures/itunes/lookup-huberman-lab.json create mode 100644 tests/fixtures/itunes/lookup-unknown-id.json diff --git a/src/iTunesAPIConsumer.test.ts b/src/iTunesAPIConsumer.test.ts index b54770ed..6ca8a60e 100644 --- a/src/iTunesAPIConsumer.test.ts +++ b/src/iTunesAPIConsumer.test.ts @@ -1,7 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import lookupAppleNewsToday from "../tests/fixtures/itunes/lookup-apple-news-today.json"; +import lookupHubermanLab from "../tests/fixtures/itunes/lookup-huberman-lab.json"; +import lookupUnknownId from "../tests/fixtures/itunes/lookup-unknown-id.json"; import searchAppleNews from "../tests/fixtures/itunes/search-apple-news.json"; import { fetchJsonWithTimeout, NetworkError } from "./utility/networkRequest"; -import { queryiTunesPodcasts } from "./iTunesAPIConsumer"; +import { + lookupiTunesPodcast, + parseApplePodcastsId, + queryiTunesPodcasts, +} from "./iTunesAPIConsumer"; vi.mock("./utility/networkRequest", async (importOriginal) => { const actual = await importOriginal(); @@ -76,3 +83,84 @@ describe("queryiTunesPodcasts", () => { ); }); }); + +describe("parseApplePodcastsId", () => { + it.each([ + ["https://podcasts.apple.com/us/podcast/huberman-lab/id1545953110", "1545953110"], + ["https://podcasts.apple.com/podcast/huberman-lab/id1545953110", "1545953110"], + ["https://podcasts.apple.com/us/podcast/id1545953110", "1545953110"], + ["https://podcasts.apple.com/us/podcast/huberman-lab/id1545953110?uo=4", "1545953110"], + [ + "https://podcasts.apple.com/us/podcast/huberman-lab/id1545953110?i=1000650000000", + "1545953110", + ], + ["https://podcasts.apple.com/us/podcast/ideas/id151485663?uo=4", "151485663"], + [" https://podcasts.apple.com/us/podcast/huberman-lab/id1545953110\n", "1545953110"], + ["http://podcasts.apple.com/us/podcast/huberman-lab/id1545953110", "1545953110"], + ["https://itunes.apple.com/us/podcast/huberman-lab/id1545953110?mt=2", "1545953110"], + [ + "https://itunes.apple.com/WebObjects/MZStore.woa/wa/viewPodcast?id=1545953110", + "1545953110", + ], + ["https://podcasts.apple.com/podcast?id=1545953110", "1545953110"], + ])("reads the show id from %s", (input, id) => { + expect(parseApplePodcastsId(input)).toBe(id); + }); + + it.each([ + "https://podcasts.apple.com.evil.example/us/podcast/huberman-lab/id1545953110", + "https://evil.example/us/podcast/huberman-lab/id1545953110", + "ftp://podcasts.apple.com/us/podcast/huberman-lab/id1545953110", + "podcasts://podcasts.apple.com/us/podcast/huberman-lab/id1545953110", + "https://podcasts.apple.com/us/channel/scicomm-media/id6442692632", + "https://podcasts.apple.com/us/podcast/huberman-lab", + "https://podcasts.apple.com/us/podcast/huberman-lab/idhuberman", + "https://podcasts.apple.com/podcast?id=huberman", + "https://podcasts.apple.com/us/browse?id=1545953110", + "https://feeds.megaphone.fm/hubermanlab", + "huberman lab", + ])("rejects %s", (input) => { + expect(parseApplePodcastsId(input)).toBeNull(); + }); +}); + +describe("lookupiTunesPodcast", () => { + it("resolves a show id to its RSS feed through a bounded request", async () => { + fetchJsonMock.mockResolvedValue(lookupHubermanLab); + + await expect(lookupiTunesPodcast("1545953110")).resolves.toEqual({ + title: "Huberman Lab", + url: "https://feeds.megaphone.fm/hubermanlab", + artworkUrl: + "https://is1-ssl.mzstatic.com/image/thumb/Podcasts221/v4/9a/d3/19/9ad31912-0b5a-a16e-2d7c-9fd074698b9c/mza_8994222203629500925.jpg/100x100bb.jpg", + collectionId: "1545953110", + }); + const url = requestedUrl(); + expect(url.origin + url.pathname).toBe("https://itunes.apple.com/lookup"); + expect(url.searchParams.get("id")).toBe("1545953110"); + expect(url.searchParams.get("entity")).toBe("podcast"); + expect(fetchJsonMock).toHaveBeenCalledWith(expect.any(String), { + timeoutMs: 15_000, + maxResponseBytes: 2 * 1024 * 1024, + acceptedStatuses: [200], + }); + }); + + it("returns null for an id Apple does not know", async () => { + fetchJsonMock.mockResolvedValue(lookupUnknownId); + + await expect(lookupiTunesPodcast("1")).resolves.toBeNull(); + }); + + it("returns null for a show without a public RSS feed", async () => { + fetchJsonMock.mockResolvedValue(lookupAppleNewsToday); + + await expect(lookupiTunesPodcast("1473872585")).resolves.toBeNull(); + }); + + it("rejects on a failed request instead of reporting a missing feed", async () => { + fetchJsonMock.mockRejectedValue(new NetworkError("timeout")); + + await expect(lookupiTunesPodcast("1545953110")).rejects.toMatchObject({ code: "timeout" }); + }); +}); diff --git a/src/iTunesAPIConsumer.ts b/src/iTunesAPIConsumer.ts index f1a320a2..266508d7 100644 --- a/src/iTunesAPIConsumer.ts +++ b/src/iTunesAPIConsumer.ts @@ -1,8 +1,10 @@ import type { PodcastFeed } from "./types/PodcastFeed"; +import checkStringIsUrl from "./utility/checkStringIsUrl"; import { NetworkError, fetchJsonWithTimeout } from "./utility/networkRequest"; const ITUNES_REQUEST_TIMEOUT_MS = 15_000; const MAX_ITUNES_RESPONSE_BYTES = 2 * 1024 * 1024; +const APPLE_PODCASTS_HOSTS = new Set(["podcasts.apple.com", "itunes.apple.com"]); interface iTunesResult { collectionName: string; @@ -24,6 +26,38 @@ export async function queryiTunesPodcasts(query: string): Promise return fetchPodcastFeeds(url); } +/** Resolve an Apple Podcasts show id to its public RSS feed, or null when it has none. */ +export async function lookupiTunesPodcast(id: string): Promise { + const url = new URL("https://itunes.apple.com/lookup"); + url.searchParams.append("id", id); + url.searchParams.append("entity", "podcast"); + const [feed] = await fetchPodcastFeeds(url); + return feed ?? null; +} + +/** + * Extract the show id from an Apple Podcasts link: a `podcast` or `viewPodcast` + * path with the id in an `id` segment or an `id` query parameter. + */ +export function parseApplePodcastsId(input: string): string | null { + const url = checkStringIsUrl(input); + if ( + !url || + (url.protocol !== "https:" && url.protocol !== "http:") || + !APPLE_PODCASTS_HOSTS.has(url.hostname) + ) { + return null; + } + + const segments = url.pathname.split("/"); + if (!segments.some((segment) => segment === "podcast" || segment === "viewPodcast")) { + return null; + } + const id = + segments.find((segment) => /^id\d+$/.test(segment))?.slice(2) ?? url.searchParams.get("id"); + return id && /^\d+$/.test(id) ? id : null; +} + async function fetchPodcastFeeds(url: URL): Promise { try { const data = await fetchJsonWithTimeout(url.href, { diff --git a/src/ui/settings/PodcastQueryGrid.svelte b/src/ui/settings/PodcastQueryGrid.svelte index becd8ed0..8a78c05a 100644 --- a/src/ui/settings/PodcastQueryGrid.svelte +++ b/src/ui/settings/PodcastQueryGrid.svelte @@ -1,6 +1,10 @@