diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx
index e6c1842..0020a19 100644
--- a/src/pages/Search/SearchPage.tsx
+++ b/src/pages/Search/SearchPage.tsx
@@ -26,6 +26,7 @@ import {
} from "solid-js";
import { useSearchStore } from "@/stores/search";
import { useFeedStore } from "@/stores/feed";
+import { useToast } from "@/ui/toast";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import {
@@ -49,6 +50,7 @@ export const SearchPaneCount = 1;
function SearchPage() {
const searchStore = useSearchStore();
const feedStore = useFeedStore();
+ const toast = useToast();
const [inputValue, setInputValue] = createSignal("");
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
@@ -136,10 +138,23 @@ function SearchPage() {
runSearch(query);
};
- const handleSubscribe = (result: SearchResult) => {
- // Actually add the feed to the feed store, then mark the result subscribed
- feedStore.addFeed(result.podcast, result.sourceId).catch(() => {});
- searchStore.markSubscribed(result.podcast.id);
+ const handleSubscribe = async (result: SearchResult) => {
+ // Actually add the feed to the feed store, then mark the result
+ // subscribed. addFeed returns null when a feedless directory stub
+ // (delisted show) can't be resolved — tell the user why.
+ const feed = await feedStore
+ .addFeed(result.podcast, result.sourceId)
+ .catch(() => null);
+ if (!feed && !result.podcast.feedUrl) {
+ toast.show({
+ title: "Can't subscribe",
+ message:
+ "No RSS feed is listed for this show and the feed couldn't be resolved. Try adding it by feed URL.",
+ variant: "error",
+ });
+ return;
+ }
+ if (feed) searchStore.markSubscribed(result.podcast.id);
};
// ── nav.action handler ──────────────────────────────────────────────────────
@@ -450,7 +465,11 @@ function SearchPage() {
- Feed: {result().podcast.feedUrl}
+
+ Feed:{" "}
+ {result().podcast.feedUrl ||
+ "not listed by source — resolves on subscribe"}
+
Updated: {formatDate(result().podcast.lastUpdated)}
diff --git a/src/stores/feed.ts b/src/stores/feed.ts
index f2608c8..48beb30 100644
--- a/src/stores/feed.ts
+++ b/src/stores/feed.ts
@@ -11,6 +11,7 @@ import type { Episode } from "../types/episode";
import type { PodcastSource } from "../types/source";
import { DEFAULT_SOURCES } from "../types/source";
import { parseRSSFeed } from "../api/rss-parser";
+import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
import {
loadFeedsFromFile,
saveFeedsToFile,
@@ -196,6 +197,17 @@ function createFeedStore() {
sourceId: string,
visibility: FeedVisibility = FeedVisibility.PUBLIC,
): Promise => {
+ // A directory stub (e.g. a show delisted from Apple Podcasts) has no
+ // feed URL; resolve the real feed from its directory page before
+ // subscribing. Refuse when it can't be resolved rather than adding a
+ // broken feed.
+ if (!podcast.feedUrl) {
+ if (!podcast.directoryUrl) return null;
+ const resolved = await resolveItunesFeedUrl(podcast.directoryUrl);
+ if (!resolved) return null;
+ podcast = { ...podcast, feedUrl: resolved, directoryUrl: undefined };
+ }
+
// Guard: don't add a feed we already have (matched by feedUrl)
if (hasFeedByUrl(podcast.feedUrl)) {
return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null;
diff --git a/src/types/podcast.ts b/src/types/podcast.ts
index 3ccd595..27ef500 100644
--- a/src/types/podcast.ts
+++ b/src/types/podcast.ts
@@ -12,8 +12,12 @@ export interface Podcast {
description: string
/** Cover image URL */
coverUrl?: string
- /** RSS feed URL */
+ /** RSS feed URL. Empty when the directory lists the show without a feed
+ * (e.g. shows delisted from Apple Podcasts); see directoryUrl. */
feedUrl: string
+ /** Directory listing page (e.g. Apple Podcasts) for shows whose feed URL
+ * the directory omits — used to resolve the real feed at subscribe time. */
+ directoryUrl?: string
/** Author/creator name */
author?: string
/** Podcast categories */
diff --git a/src/utils/itunes-feed-resolver.ts b/src/utils/itunes-feed-resolver.ts
new file mode 100644
index 0000000..ffb0de4
--- /dev/null
+++ b/src/utils/itunes-feed-resolver.ts
@@ -0,0 +1,61 @@
+/**
+ * iTunes feed resolution for shows delisted from Apple Podcasts.
+ *
+ * The iTunes Search API returns `feedUrl: null` for shows that left Apple
+ * Podcasts (e.g. The Daily Wire's shows in 2021) — the directory keeps a
+ * metadata-only stub. The show's public Apple Podcasts page still embeds the
+ * real feed URL in its JSON state (`showOffer.feedUrl`), so subscribing can
+ * resolve it from there.
+ */
+
+/** `"feedUrl":"https://..."` as embedded in the Apple page's JSON state. */
+const FEED_URL_RE = /"feedUrl"\s*:\s*"(https?:\/\/[^"]+)"/
+
+/**
+ * Extract the show's feed URL from an Apple Podcasts page's HTML.
+ *
+ * The page embeds `showOffer` blocks for the show AND for related shows, each
+ * with its own feedUrl, and Apple serves multiple JSON variants — the main
+ * show's showOffer may sit adjacent to its adamId or thousands of chars later.
+ * Anchor on the collection id from `directoryUrl` (`"adamId":""`) and take
+ * the FIRST feedUrl after it (the main show's content precedes related shows'
+ * in the document). Falls back to the first feedUrl in the document only when
+ * the id isn't present in the URL. Returns null when no trustworthy match
+ * exists (page restructured, no feed) — callers must not guess.
+ */
+export const extractFeedUrlFromPage = (
+ html: string,
+ directoryUrl: string,
+): string | null => {
+ const idMatch = /[?/]id(\d+)/.exec(directoryUrl)
+ if (!idMatch) {
+ const fallback = FEED_URL_RE.exec(html)
+ return fallback ? fallback[1] : null
+ }
+
+ const adamIdx = html.search(new RegExp(`"adamId"\\s*:\\s*"${idMatch[1]}"`))
+ if (adamIdx < 0) return null
+
+ const fromAdam = new RegExp(FEED_URL_RE.source, "g")
+ fromAdam.lastIndex = adamIdx
+ const match = fromAdam.exec(html)
+ return match ? match[1] : null
+}
+
+/**
+ * Resolve a delisted show's RSS feed from its Apple Podcasts page.
+ * Returns null on network failure or when the page has no resolvable feed.
+ */
+export const resolveItunesFeedUrl = async (
+ directoryUrl: string,
+): Promise => {
+ try {
+ const response = await fetch(directoryUrl, {
+ headers: { "User-Agent": "PodTUI/1.0" },
+ })
+ if (!response.ok) return null
+ return extractFeedUrlFromPage(await response.text(), directoryUrl)
+ } catch {
+ return null
+ }
+}
diff --git a/src/utils/source-searcher.ts b/src/utils/source-searcher.ts
index 9278018..eeea64a 100644
--- a/src/utils/source-searcher.ts
+++ b/src/utils/source-searcher.ts
@@ -14,11 +14,13 @@ type ItunesResult = {
collectionId?: number
collectionName?: string
artistName?: string
- feedUrl?: string
+ /** Null for shows delisted from Apple Podcasts (directory stub records). */
+ feedUrl?: string | null
artworkUrl100?: string
artworkUrl600?: string
primaryGenreName?: string
releaseDate?: string
+ collectionViewUrl?: string
}
type ItunesResponse = {
@@ -41,8 +43,8 @@ const buildItunesUrl = (query: string, source: PodcastSource) => {
return url.toString()
}
-const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast | null => {
- if (!result.collectionName || !result.feedUrl) return null
+export const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast | null => {
+ if (!result.collectionName) return null
const id = result.collectionId
? `itunes-${result.collectionId}`
@@ -52,11 +54,18 @@ const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast |
if (result.artistName) descriptionParts.push(`by ${result.artistName}`)
if (result.primaryGenreName) descriptionParts.push(result.primaryGenreName)
+ // Shows delisted from Apple Podcasts (e.g. The Daily Wire's shows) come back
+ // as metadata-only stub records with feedUrl null. Keep the stub so the show
+ // stays findable; the real feed is resolved from the directory page at
+ // subscribe time (see itunes-feed-resolver).
+ const feedUrl = result.feedUrl ?? ""
+
return {
id,
title: result.collectionName,
description: descriptionParts.join(" • "),
- feedUrl: result.feedUrl,
+ feedUrl,
+ directoryUrl: feedUrl ? undefined : result.collectionViewUrl,
author: result.artistName,
categories: result.primaryGenreName ? [result.primaryGenreName] : undefined,
coverUrl: result.artworkUrl600 || result.artworkUrl100,
diff --git a/tests/feedless-subscribe.test.ts b/tests/feedless-subscribe.test.ts
new file mode 100644
index 0000000..a317820
--- /dev/null
+++ b/tests/feedless-subscribe.test.ts
@@ -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 ``;
+}
+
+function feedXml(origin: string): string {
+ const items = EPISODE_TITLES.map(
+ (title, i) => `-
+${title}
+2026-08-0${2 - i}T00:00:00Z
+
+
`,
+ ).join("\n");
+ return `
+
+Delisted Show
+Feedless stub test
+${items}
+`;
+}
+
+let server: ReturnType | 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` 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();
+});
diff --git a/tests/itunes-feed-resolver.test.ts b/tests/itunes-feed-resolver.test.ts
new file mode 100644
index 0000000..3c382a7
--- /dev/null
+++ b/tests/itunes-feed-resolver.test.ts
@@ -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(
+ "not found",
+ "https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
+ );
+ expect(feed).toBeNull();
+});
diff --git a/tests/source-searcher.test.ts b/tests/source-searcher.test.ts
index 9a4efe9..3e8d6ba 100644
--- a/tests/source-searcher.test.ts
+++ b/tests/source-searcher.test.ts
@@ -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();
+});