Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0bc049a
feat: list up to 20 podcast search results
chhoumann Oct 11, 2026
03def58
feat: add podcasts from Apple Podcasts show links
chhoumann Oct 11, 2026
759fdcc
fix: show each podcast feed once in search results
chhoumann Oct 11, 2026
4a4d279
fix: ignore stale podcast search responses
chhoumann Oct 11, 2026
49f83a8
fix: read the show id from the last id segment of Apple Podcasts links
chhoumann Oct 11, 2026
c466b25
fix: resolve Apple Podcasts links only to the linked show
chhoumann Oct 11, 2026
bf68b13
refactor: dedupe feeds only in podcast search
chhoumann Oct 11, 2026
3f93d93
test: keep the stale-search test independent of feed dedupe
chhoumann Oct 11, 2026
b261dab
test: cover a stale Apple Podcasts lookup answering after a newer search
chhoumann Oct 11, 2026
c1d085a
test: reproduce a stale search answering during the next input's debo…
chhoumann Oct 11, 2026
1d72b26
fix: drop a stale search response as soon as the input changes
chhoumann Oct 11, 2026
2e6d7d2
refactor: pass the input count to the debounced podcast search
chhoumann Oct 11, 2026
5b897aa
test: answer the stale-search race with a recorded search response
chhoumann Oct 11, 2026
b4d0844
test: resolve the stale Apple Podcasts lookup while the newer search …
chhoumann Oct 11, 2026
cf9a436
test: name the stale Apple Podcasts lookup test after its order
chhoumann Oct 11, 2026
6189d80
Merge remote-tracking branch 'origin/master' into feat/search-apple-l…
chhoumann Oct 11, 2026
fa2d746
fix: never replace a saved podcast with a namesake
chhoumann Oct 11, 2026
894f73b
feat: show the author on podcast search results
chhoumann Oct 11, 2026
c4d8026
test: cover encoded search terms and failed Apple Podcasts lookups
chhoumann Oct 11, 2026
6791d53
fix: match saved feeds by own title and resolved URL in search
chhoumann Oct 11, 2026
20ba930
test: pin the namesake notice and keep refused private URLs out of Se…
chhoumann Oct 11, 2026
137b07f
docs: name both sources of a podcast's author
chhoumann Oct 11, 2026
f2a760c
fix: word the namesake notice so it holds for the same show under ano…
chhoumann Oct 11, 2026
38bcfc1
Merge branch 'master' into feat/search-apple-links
chhoumann Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 168 additions & 30 deletions src/iTunesAPIConsumer.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import lookupAppleIncArtist from "../tests/fixtures/itunes/lookup-apple-inc-artist.json";
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 searchPlanetMoney from "../tests/fixtures/itunes/search-planet-money.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<typeof import("./utility/networkRequest")>();
Expand All @@ -9,42 +19,81 @@ 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);

await queryiTunesPodcasts("apple news");

afterEach(() => vi.restoreAllMocks());

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("sends the search term intact alongside the podcast filters", async () => {
fetchJsonMock.mockResolvedValue(searchAppleNews);

await queryiTunesPodcasts("Ça & va + bien");

const url = requestedUrl();
expect(url.searchParams.get("term")).toBe("Ça & va + bien");
expect(url.searchParams.get("media")).toBe("podcast");
expect(url.searchParams.get("kind")).toBe("podcast");
expect(url.searchParams.get("limit")).toBe("20");
});

await expect(queryiTunesPodcasts("example & show")).resolves.toEqual([
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("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",
author: "Apple News",
},
]);
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",
author: "Apple Inc.",
},
);
]);
});

it("lists each feed once, under its best-ranked show", async () => {
// Planet Money's feed comes back as three shows: ranks 1, 4, and 5.
fetchJsonMock.mockResolvedValue(searchPlanetMoney);

const feeds = await queryiTunesPodcasts("planet money");

expect(feeds.map(({ collectionId, url }) => [collectionId, url])).toEqual([
["290783428", "https://feeds.npr.org/510289/podcast.xml"],
["1320118593", "https://feeds.npr.org/510325/podcast.xml"],
["1578892272", "https://feeds.npr.org/510362/podcast.xml"],
]);
});

it("turns an invalid response shape into a redacted boundary error", async () => {
Expand All @@ -57,9 +106,98 @@ 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,
);
});
});

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/id10/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",
author: "Scicomm Media",
});
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 an artist id instead of one of the artist's shows", async () => {
fetchJsonMock.mockResolvedValue(lookupAppleIncArtist);

await expect(lookupiTunesPodcast("706424103")).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" });
});
});
83 changes: 69 additions & 14 deletions src/iTunesAPIConsumer.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,77 @@
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;
feedUrl: string;
artistName?: string;
feedUrl?: string;
artworkUrl100: string;
collectionId: string;
collectionId: number;
}

interface iTunesSearchResponse {
interface iTunesResponse {
results: iTunesResult[];
}

export async function queryiTunesPodcasts(query: string): Promise<PodcastFeed[]> {
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");

// Search can list one feed under several shows; keep the best-ranked one.
const feeds = new Map<string, PodcastFeed>();
for (const feed of await fetchPodcastFeeds(url)) {
if (!feeds.has(feed.url)) feeds.set(feed.url, feed);
}
return [...feeds.values()];
}

/** Resolve an Apple Podcasts show id to its public RSS feed, or null when it has none. */
export async function lookupiTunesPodcast(id: string): Promise<PodcastFeed | null> {
const url = new URL("https://itunes.apple.com/lookup");
url.searchParams.append("id", id);
url.searchParams.append("entity", "podcast");
// An artist id returns the artist's shows, none of which is the linked show.
const feeds = await fetchPodcastFeeds(url);
return feeds.find((feed) => feed.collectionId === id) ?? null;
}

/**
* Extract the show id from an Apple Podcasts link: a `podcast` or `viewPodcast`
* path with the id in its last `id<digits>` 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
.filter((segment) => /^id\d+$/.test(segment))
.pop()
?.slice(2) ?? url.searchParams.get("id");
return id && /^\d+$/.test(id) ? id : null;
}

async function fetchPodcastFeeds(url: URL): Promise<PodcastFeed[]> {
try {
const data = await fetchJsonWithTimeout<iTunesSearchResponse>(url.href, {
const data = await fetchJsonWithTimeout<iTunesResponse>(url.href, {
timeoutMs: ITUNES_REQUEST_TIMEOUT_MS,
maxResponseBytes: MAX_ITUNES_RESPONSE_BYTES,
acceptedStatuses: [200],
Expand All @@ -32,21 +80,28 @@ export async function queryiTunesPodcasts(query: string): Promise<PodcastFeed[]>
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),
...(typeof result.artistName === "string" ? { author: result.artistName } : {}),
};
}
17 changes: 15 additions & 2 deletions src/services/privateFeeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ import type { FeedUrlRepository } from "./FeedUrlRepository";
* Ingress (subscribe, OPML import, settings import, load-time migration)
* interns a credential-bearing URL into SecretStorage and persists only a
* placeholder. Egress (`resolveFeedUrl`) turns a saved feed back into a
* fetchable URL immediately before retrieval and nowhere else - callers keep
* the resolved value confined to the fetch call and never persist or log it.
* fetchable URL immediately before retrieval - callers keep the resolved
* value confined to the fetch call and never persist or log it. Outside a
* fetch, a secret is resolved only to compare it with a URL already in hand
* (`isSavedFeed`, which returns just the answer) or to check that it exists
* on this device. OPML export is the one deliberate exception: the user asks
* for a file that carries the real feed URLs.
*/

/**
Expand Down Expand Up @@ -58,6 +62,15 @@ export function resolveFeedUrl(feed: PodcastFeed, feedUrls: FeedUrlRepository):
return isPrivateFeedPlaceholder(feed.url) ? null : feed.url;
}

/**
* Whether `url` is the saved feed's URL. A private feed matches both its
* placeholder and the URL the placeholder stands for, so the same feed reads
* as saved whether it is listed from settings or pasted again.
*/
export function isSavedFeed(saved: PodcastFeed, url: string, feedUrls: FeedUrlRepository): boolean {
return saved.url === url || resolveFeedUrl(saved, feedUrls) === url;
}

/** resolveFeedUrl plus the standard user-facing failure Notice. */
export function resolveFeedUrlWithNotice(
feed: PodcastFeed,
Expand Down
5 changes: 4 additions & 1 deletion src/types/PodcastFeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export interface PodcastFeed {
description?: string;
/** Channel <link> — the podcast's website/homepage (not the RSS url). */
link?: string;
/** <itunes:author> (falls back to <author> / <managingEditor>). */
/**
* <itunes:author> (falls back to <author> / <managingEditor>), or the Apple
* Podcasts artist name for feeds found through Apple Podcasts search or links.
*/
author?: string;
}
Loading
Loading