fix(search): surface iTunes stubs and resolve feeds for delisted shows

Shows that left Apple Podcasts (e.g. Daily Wire's in 2021) come back from
the iTunes Search API as metadata-only stub records with feedUrl null.
mapItunesResult dropped them, so The Ben Shapiro Show — the #1 hit for
'ben shapiro' — never appeared in search while sibling shows did.

- Keep feedUrl-less results (feedUrl "" + directoryUrl pointing at the
  Apple page) so delisted shows stay findable.
- Resolve the real feed from the Apple page at subscribe time
  (itunes-feed-resolver: anchor on the collection's adamId, forward-scan
  for the embedded feedUrl; Apple serves page variants where the
  showOffer block sits thousands of chars after the adamId).
- addFeed refuses feedless stubs whose feed can't be resolved instead of
  adding a broken feed; SearchPage surfaces the failure via toast.
- Tests: stub mapping, extractor variants, and an end-to-end subscribe
  over a local HTTP server.
This commit is contained in:
2026-08-10 22:38:03 -04:00
parent e73e608b9f
commit 0b0637b9dc
8 changed files with 364 additions and 11 deletions

View File

@@ -0,0 +1,117 @@
/**
* End-to-end subscribe test for feedless directory stubs.
*
* A delisted show (feedUrl "" + directoryUrl) must resolve its real feed from
* the directory page inside addFeed — so subscribing just works. When the
* page can't be resolved, addFeed must refuse (return null) instead of adding
* a broken feed. Served over a real local HTTP server, mirroring how the
* app's other store tests exercise the network path.
*/
import { test, expect, beforeAll, afterAll } from "bun:test";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
// Point the config dir at a throwaway directory BEFORE importing the stores
// (their module-level init reads it).
const configHome = mkdtempSync(join(tmpdir(), "podtui-feedless-"));
process.env.XDG_CONFIG_HOME = configHome;
import { useFeedStore } from "../src/stores/feed";
import type { Podcast } from "../src/types/podcast";
const FEED_ID = "12345";
const EPISODE_TITLES = ["Ep 2", "Ep 1"];
function pageHtml(feedUrl: string): string {
return `<html><body><script>
{"pageData":{"showOffer":{"title":"Delisted Show","adamId":"${FEED_ID}","feedUrl":"${feedUrl}","showType":"episodic"}}}
</script></body></html>`;
}
function feedXml(origin: string): string {
const items = EPISODE_TITLES.map(
(title, i) => `<item>
<title>${title}</title>
<pubDate>2026-08-0${2 - i}T00:00:00Z</pubDate>
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
</item>`,
).join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<title>Delisted Show</title>
<description>Feedless stub test</description>
${items}
</channel></rss>`;
}
let server: ReturnType<typeof Bun.serve> | null = null;
let feedUrl = "";
let pageUrl = "";
beforeAll(() => {
server = Bun.serve({
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname.endsWith(".rss")) {
return new Response(feedXml(url.origin), {
headers: { "Content-Type": "application/rss+xml" },
});
}
if (url.pathname.startsWith("/show")) {
return new Response(pageHtml(feedUrl), {
headers: { "Content-Type": "text/html" },
});
}
return new Response("not found", { status: 404 });
},
});
feedUrl = `http://127.0.0.1:${server!.port}/feed.rss`;
// The resolver anchors on `/id<digits>` in the directory URL.
pageUrl = `http://127.0.0.1:${server!.port}/show/id${FEED_ID}`;
});
afterAll(() => {
server?.stop(true);
rmSync(configHome, { recursive: true, force: true });
});
const makeStub = (feedUrl: string, directoryUrl?: string): Podcast => ({
id: "itunes-12345",
title: "Delisted Show",
description: "Show that left the directory",
author: "Some Network",
feedUrl,
directoryUrl,
lastUpdated: new Date(),
isSubscribed: false,
});
test("addFeed resolves a feedless stub's feed from its directory page", async () => {
const store = useFeedStore();
const feed = await store.addFeed(makeStub("", pageUrl), "itunes");
expect(feed).not.toBeNull();
expect(feed!.podcast.feedUrl).toBe(feedUrl);
// Resolution metadata is dropped from the persisted feed record.
expect(feed!.podcast.directoryUrl).toBeUndefined();
expect(feed!.episodes.map((e) => e.title)).toEqual(EPISODE_TITLES);
// Remove the feed: bun test shares the store singleton across files, and a
// leftover feed (whose server dies in afterAll) would reorder other files'
// refresh assertions.
store.removeFeed(feed!.id);
});
test("addFeed refuses a stub whose directory page cannot be resolved", async () => {
const store = useFeedStore();
const unreachable = makeStub("", "http://127.0.0.1:1/nope/id999");
const feed = await store.addFeed(unreachable, "itunes");
expect(feed).toBeNull();
});
test("addFeed refuses a stub with no directory page at all", async () => {
const store = useFeedStore();
const bare = makeStub("", undefined);
const feed = await store.addFeed(bare, "itunes");
expect(feed).toBeNull();
});

View File

@@ -0,0 +1,70 @@
/**
* Feed-resolution extraction tests.
*
* The iTunes Search API returns feedUrl null for shows delisted from Apple
* Podcasts. Their public Apple page still embeds the real feed URL in JSON
* state — alongside feedUrls of RELATED shows — so extraction must anchor on
* the show's adamId rather than grabbing the first feedUrl in the document.
*/
import { test, expect } from "bun:test";
import { extractFeedUrlFromPage } from "../src/utils/itunes-feed-resolver";
const MAIN_FEED = "https://rss.pdrl.fm/b32227/feeds.megaphone.fm/BVDWV5370667266";
const OTHER_FEED = "https://feeds.megaphone.fm/BVDWV7762869899";
/** Synthetic Apple page: related shows first, main showOffer after. */
const pageWithNoise = `{
"shows":[{"showOffer":{"title":"The Matt Walsh Show","adamId":"2950206264","feedUrl":"${OTHER_FEED}","showType":"episodic"}}],
"pageData":{"showOffer":{"title":"The Ben Shapiro Show","adamId":"1047335260","feedUrl":"${MAIN_FEED}","showType":"episodic"}}
}`;
test("extracts the show's feed anchored on its adamId, ignoring related shows", () => {
const feed = extractFeedUrlFromPage(
pageWithNoise,
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
);
expect(feed).toBe(MAIN_FEED);
});
test("handles the ?uo=4 suffix Apple appends to directory URLs", () => {
const feed = extractFeedUrlFromPage(
pageWithNoise,
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260?uo=4",
);
expect(feed).toBe(MAIN_FEED);
});
test("returns null when the page has no feedUrl for the requested id", () => {
const feed = extractFeedUrlFromPage(
pageWithNoise,
"https://podcasts.apple.com/us/podcast/some-other-show/id9999999999",
);
expect(feed).toBeNull();
});
test("finds the feed when the showOffer sits far after the adamId reference", () => {
// Apple serves page variants where thousands of chars separate the first
// adamId reference from the showOffer block carrying the feedUrl.
const variant = `{"adamId":"1047335260","$kind":"ShowPageIntent"}${"x".repeat(6000)}{"showOffer":{"title":"The Ben Shapiro Show","adamId":"1047335260","feedUrl":"${MAIN_FEED}"}}`;
const feed = extractFeedUrlFromPage(
variant,
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
);
expect(feed).toBe(MAIN_FEED);
});
test("falls back to the first feedUrl when the URL carries no id", () => {
const feed = extractFeedUrlFromPage(
pageWithNoise,
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show",
);
expect(feed).toBe(OTHER_FEED);
});
test("returns null when the page contains no feedUrl at all", () => {
const feed = extractFeedUrlFromPage(
"<html><body>not found</body></html>",
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
);
expect(feed).toBeNull();
});

View File

@@ -9,7 +9,7 @@
* search. This test pins the empty-result contract.
*/
import { test, expect } from "bun:test";
import { searchSourceByType } from "../src/utils/source-searcher";
import { searchSourceByType, mapItunesResult } from "../src/utils/source-searcher";
import { SourceType } from "../src/types/source";
import type { PodcastSource } from "../src/types/source";
@@ -29,6 +29,16 @@ const customSource: PodcastSource = {
enabled: true,
};
const itunesSource: PodcastSource = {
id: "itunes",
name: "Apple Podcasts",
type: SourceType.API,
baseUrl: "https://itunes.apple.com/search",
enabled: true,
country: "US",
language: "en_us",
};
test("RSS sources return no directory search results", async () => {
const results = await searchSourceByType("blocked and reported", rssSource);
expect(results).toEqual([]);
@@ -38,3 +48,54 @@ test("custom sources return no directory search results", async () => {
const results = await searchSourceByType("anything", customSource);
expect(results).toEqual([]);
});
// ── iTunes stub records (delisted shows) ────────────────────────────────────
// Shows that left Apple Podcasts (e.g. The Daily Wire's in 2021) remain in
// the directory as metadata-only records with feedUrl null. They must stay
// findable — earlier they were dropped entirely, so "ben shapiro" surfaced
// nothing while the show is the #1 iTunes hit.
test("iTunes results without a feedUrl (delisted shows) are kept, not dropped", () => {
const stub = mapItunesResult(
{
collectionId: 1047335260,
collectionName: "The Ben Shapiro Show",
artistName: "The Daily Wire",
feedUrl: null,
collectionViewUrl:
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
},
itunesSource,
);
expect(stub).not.toBeNull();
expect(stub!.title).toBe("The Ben Shapiro Show");
// Empty feed marks "unavailable from this directory"; the Apple page URL
// is carried for feed resolution at subscribe time.
expect(stub!.feedUrl).toBe("");
expect(stub!.directoryUrl).toBe(
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
);
});
test("iTunes results with a feedUrl keep it and carry no directory fallback", () => {
const normal = mapItunesResult(
{
collectionId: 1487234816,
collectionName: "Morning Wire",
artistName: "The Daily Wire",
feedUrl: "https://feeds.megaphone.fm/BVDWV8747925072",
},
itunesSource,
);
expect(normal).not.toBeNull();
expect(normal!.feedUrl).toBe("https://feeds.megaphone.fm/BVDWV8747925072");
expect(normal!.directoryUrl).toBeUndefined();
});
test("iTunes results without a collection name stay dropped", () => {
const dropped = mapItunesResult(
{ collectionId: 1, feedUrl: "https://example.com/feed.xml" },
itunesSource,
);
expect(dropped).toBeNull();
});