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:
@@ -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() {
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={muted()}>Feed: {result().podcast.feedUrl}</text>
|
||||
<text fg={muted()}>
|
||||
Feed:{" "}
|
||||
{result().podcast.feedUrl ||
|
||||
"not listed by source — resolves on subscribe"}
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
Updated: {formatDate(result().podcast.lastUpdated)}
|
||||
</text>
|
||||
|
||||
@@ -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<Feed | null> => {
|
||||
// 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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
61
src/utils/itunes-feed-resolver.ts
Normal file
61
src/utils/itunes-feed-resolver.ts
Normal file
@@ -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":"<id>"`) 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<string | null> => {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user