diff --git a/src/iTunesAPIConsumer.test.ts b/src/iTunesAPIConsumer.test.ts index ab32a48b..4274c18c 100644 --- a/src/iTunesAPIConsumer.test.ts +++ b/src/iTunesAPIConsumer.test.ts @@ -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(); @@ -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 () => { @@ -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" }); + }); +}); diff --git a/src/iTunesAPIConsumer.ts b/src/iTunesAPIConsumer.ts index 44aa817d..dc11ab50 100644 --- a/src/iTunesAPIConsumer.ts +++ b/src/iTunesAPIConsumer.ts @@ -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 { - 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(); + 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 { + 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` 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 { 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 +80,28 @@ 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), + ...(typeof result.artistName === "string" ? { author: result.artistName } : {}), + }; +} diff --git a/src/services/privateFeeds.ts b/src/services/privateFeeds.ts index 5c1c1e61..716a8eab 100644 --- a/src/services/privateFeeds.ts +++ b/src/services/privateFeeds.ts @@ -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. */ /** @@ -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, diff --git a/src/types/PodcastFeed.ts b/src/types/PodcastFeed.ts index 81f0303b..d8ef768f 100644 --- a/src/types/PodcastFeed.ts +++ b/src/types/PodcastFeed.ts @@ -13,6 +13,9 @@ export interface PodcastFeed { description?: string; /** Channel — the podcast's website/homepage (not the RSS url). */ link?: string; - /** (falls back to / ). */ + /** + * (falls back to / ), or the Apple + * Podcasts artist name for feeds found through Apple Podcasts search or links. + */ author?: string; } diff --git a/src/ui/settings/PodcastQueryGrid.svelte b/src/ui/settings/PodcastQueryGrid.svelte index becd8ed0..f1bab956 100644 --- a/src/ui/settings/PodcastQueryGrid.svelte +++ b/src/ui/settings/PodcastQueryGrid.svelte @@ -1,10 +1,18 @@