Skip to content

Commit 4ae9385

Browse files
ralyodioclaude
andcommitted
fix(podcasts): replace dead Castos search with iTunes Search API
Castos retired its admin-ajax feed lookup (now 405) and moved the RSS finder behind a Cloudflare Turnstile challenge that backend requests can't satisfy, so podcast Discover returned no results. Switch searchPodcasts() to the free, keyless Apple/iTunes Search API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6b9e773 commit 4ae9385

3 files changed

Lines changed: 100 additions & 93 deletions

File tree

src/app/podcasts/podcasts-content.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*
66
* Podcast discovery and subscription management.
77
* Features:
8-
* - Search podcasts via Castos API
8+
* - Search podcasts via the Apple Podcasts / iTunes Search API
99
* - Subscribe to podcasts (saved in Supabase)
1010
* - View subscribed podcasts and episodes
1111
* - Audio player for podcast episodes (global, persists across routes)

src/lib/podcasts/service.test.ts

Lines changed: 62 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -52,30 +52,30 @@ describe('PodcastService', () => {
5252
});
5353

5454
describe('searchPodcasts', () => {
55-
it('should search podcasts using Castos API', async () => {
56-
const mockCastosResponse = {
57-
success: true,
58-
data: [
55+
it('should search podcasts using the iTunes Search API', async () => {
56+
const mockItunesResponse = {
57+
resultCount: 2,
58+
results: [
5959
{
60-
title: 'Test Podcast',
61-
author: 'Test Author',
62-
description: 'A test podcast description',
63-
image: 'https://example.com/image.jpg',
64-
url: 'https://example.com/feed.xml',
60+
collectionName: 'Test Podcast',
61+
artistName: 'Test Author',
62+
artworkUrl600: 'https://example.com/image.jpg',
63+
feedUrl: 'https://example.com/feed.xml',
64+
collectionViewUrl: 'https://podcasts.apple.com/test',
6565
},
6666
{
67-
title: 'Another Podcast',
68-
author: 'Another Author',
69-
description: 'Another description',
70-
image: 'https://example.com/image2.jpg',
71-
url: 'https://example.com/feed2.xml',
67+
collectionName: 'Another Podcast',
68+
artistName: 'Another Author',
69+
artworkUrl600: 'https://example.com/image2.jpg',
70+
feedUrl: 'https://example.com/feed2.xml',
71+
collectionViewUrl: 'https://podcasts.apple.com/another',
7272
},
7373
],
7474
};
7575

7676
mockFetch.mockResolvedValueOnce({
7777
ok: true,
78-
json: () => Promise.resolve(mockCastosResponse),
78+
json: () => Promise.resolve(mockItunesResponse),
7979
});
8080

8181
const results = await service.searchPodcasts('test');
@@ -84,17 +84,16 @@ describe('PodcastService', () => {
8484
expect(results[0]).toEqual({
8585
title: 'Test Podcast',
8686
author: 'Test Author',
87-
description: 'A test podcast description',
87+
description: null,
8888
imageUrl: 'https://example.com/image.jpg',
8989
feedUrl: 'https://example.com/feed.xml',
90-
websiteUrl: null,
90+
websiteUrl: 'https://podcasts.apple.com/test',
9191
});
92-
expect(mockFetch).toHaveBeenCalledWith(
93-
'https://castos.com/wp-admin/admin-ajax.php',
94-
expect.objectContaining({
95-
method: 'POST',
96-
})
97-
);
92+
const [calledUrl, calledOptions] = mockFetch.mock.calls[0];
93+
expect(calledUrl).toContain('https://itunes.apple.com/search');
94+
expect(calledUrl).toContain('media=podcast');
95+
expect(calledUrl).toContain('term=test');
96+
expect(calledOptions).toMatchObject({ method: 'GET' });
9897
});
9998

10099
it('should return empty array when search fails', async () => {
@@ -111,7 +110,7 @@ describe('PodcastService', () => {
111110
it('should handle empty search results', async () => {
112111
mockFetch.mockResolvedValueOnce({
113112
ok: true,
114-
json: () => Promise.resolve({ success: true, data: [] }),
113+
json: () => Promise.resolve({ resultCount: 0, results: [] }),
115114
});
116115

117116
const results = await service.searchPodcasts('nonexistent');
@@ -122,46 +121,46 @@ describe('PodcastService', () => {
122121
it('should sanitize search query', async () => {
123122
mockFetch.mockResolvedValueOnce({
124123
ok: true,
125-
json: () => Promise.resolve({ success: true, data: [] }),
124+
json: () => Promise.resolve({ resultCount: 0, results: [] }),
126125
});
127126

128127
await service.searchPodcasts('test <script>alert("xss")</script>');
129128

130-
// Verify the FormData was created with sanitized input
131-
expect(mockFetch).toHaveBeenCalled();
129+
// The query is sanitized (script tags/special chars stripped) before being
130+
// placed in the request URL.
131+
const [calledUrl] = mockFetch.mock.calls[0];
132+
expect(calledUrl).not.toContain('script');
133+
expect(calledUrl).not.toContain('%3C'); // no encoded '<'
132134
});
133135

134-
it('should filter out results without url', async () => {
135-
const mockCastosResponse = {
136-
success: true,
137-
data: [
136+
it('should filter out results without a feed url', async () => {
137+
const mockItunesResponse = {
138+
resultCount: 3,
139+
results: [
138140
{
139-
title: 'Valid Podcast',
140-
author: 'Author',
141-
description: 'Description',
142-
image: 'https://example.com/image.jpg',
143-
url: 'https://example.com/feed.xml',
141+
collectionName: 'Valid Podcast',
142+
artistName: 'Author',
143+
artworkUrl600: 'https://example.com/image.jpg',
144+
feedUrl: 'https://example.com/feed.xml',
144145
},
145146
{
146-
title: 'Invalid Podcast - No URL',
147-
author: 'Author',
148-
description: 'Description',
149-
image: 'https://example.com/image2.jpg',
150-
// Missing url
147+
collectionName: 'Invalid Podcast - No feedUrl',
148+
artistName: 'Author',
149+
artworkUrl600: 'https://example.com/image2.jpg',
150+
// Missing feedUrl
151151
},
152152
{
153-
title: 'Invalid Podcast - Empty URL',
154-
author: 'Author',
155-
description: 'Description',
156-
image: 'https://example.com/image3.jpg',
157-
url: '',
153+
collectionName: 'Invalid Podcast - Empty feedUrl',
154+
artistName: 'Author',
155+
artworkUrl600: 'https://example.com/image3.jpg',
156+
feedUrl: '',
158157
},
159158
],
160159
};
161160

162161
mockFetch.mockResolvedValueOnce({
163162
ok: true,
164-
json: () => Promise.resolve(mockCastosResponse),
163+
json: () => Promise.resolve(mockItunesResponse),
165164
});
166165

167166
const results = await service.searchPodcasts('test');
@@ -171,29 +170,34 @@ describe('PodcastService', () => {
171170
expect(results[0].feedUrl).toBe('https://example.com/feed.xml');
172171
});
173172

174-
it('should handle url field from Castos API', async () => {
175-
const mockCastosResponse = {
176-
success: true,
177-
data: [
173+
it('should fall back to trackName and artworkUrl100 when collection fields are absent', async () => {
174+
const mockItunesResponse = {
175+
resultCount: 1,
176+
results: [
178177
{
179-
title: 'Podcast with url',
180-
author: 'Author',
181-
description: 'Description',
182-
image: 'https://example.com/image.jpg',
183-
url: 'https://example.com/feed.xml',
178+
trackName: 'Track-named Podcast',
179+
artworkUrl100: 'https://example.com/image100.jpg',
180+
feedUrl: 'https://example.com/feed.xml',
184181
},
185182
],
186183
};
187184

188185
mockFetch.mockResolvedValueOnce({
189186
ok: true,
190-
json: () => Promise.resolve(mockCastosResponse),
187+
json: () => Promise.resolve(mockItunesResponse),
191188
});
192189

193190
const results = await service.searchPodcasts('test');
194191

195192
expect(results).toHaveLength(1);
196-
expect(results[0].feedUrl).toBe('https://example.com/feed.xml');
193+
expect(results[0]).toEqual({
194+
title: 'Track-named Podcast',
195+
author: null,
196+
description: null,
197+
imageUrl: 'https://example.com/image100.jpg',
198+
feedUrl: 'https://example.com/feed.xml',
199+
websiteUrl: null,
200+
});
197201
});
198202
});
199203

src/lib/podcasts/service.ts

Lines changed: 37 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,11 @@ export interface PodcastService {
120120
// Constants
121121
// ============================================================================
122122

123-
const CASTOS_API_URL = 'https://castos.com/wp-admin/admin-ajax.php';
123+
// Apple Podcasts / iTunes Search API — free, keyless, server-friendly.
124+
// Replaced the Castos lookup tool, which moved behind a Cloudflare Turnstile
125+
// challenge that backend (server-to-server) requests cannot satisfy.
126+
const ITUNES_SEARCH_API_URL = 'https://itunes.apple.com/search';
127+
const PODCAST_SEARCH_LIMIT = 25;
124128
const COMPLETION_THRESHOLD = 0.95; // 95% = completed
125129

126130
// ============================================================================
@@ -337,25 +341,29 @@ function parseRssFeed(xml: string): ParsedPodcastFeed | null {
337341
export function createPodcastService(repository: PodcastRepository): PodcastService {
338342
return {
339343
/**
340-
* Search podcasts using Castos API
344+
* Search podcasts using the Apple Podcasts / iTunes Search API.
345+
*
346+
* The iTunes Search API is free, requires no API key, and returns the RSS
347+
* `feedUrl` we need to subscribe. It does not return a description, so that
348+
* field is null here and gets populated from the RSS feed on subscribe.
341349
*/
342350
async searchPodcasts(query: string): Promise<PodcastSearchResult[]> {
343351
const sanitizedQuery = sanitizeQuery(query);
344352
if (!sanitizedQuery) return [];
345353

346354
try {
347-
const formData = new FormData();
348-
formData.append('search', sanitizedQuery);
349-
formData.append('action', 'feed_url_lookup_search');
355+
const params = new URLSearchParams({
356+
media: 'podcast',
357+
entity: 'podcast',
358+
limit: String(PODCAST_SEARCH_LIMIT),
359+
term: sanitizedQuery,
360+
});
350361

351-
const response = await fetch(CASTOS_API_URL, {
352-
method: 'POST',
353-
body: formData,
362+
const response = await fetch(`${ITUNES_SEARCH_API_URL}?${params.toString()}`, {
363+
method: 'GET',
354364
headers: {
355-
'Accept': '*/*',
365+
'Accept': 'application/json',
356366
'User-Agent': 'podcast-search/1.0',
357-
'Referer': 'https://castos.com/tools/find-podcast-rss-feed/',
358-
'Origin': 'https://castos.com',
359367
},
360368
});
361369

@@ -364,37 +372,32 @@ export function createPodcastService(repository: PodcastRepository): PodcastServ
364372
}
365373

366374
const data = await response.json() as {
367-
success: boolean;
368-
data: Array<{
369-
title: string;
370-
author?: string;
371-
description?: string;
372-
image?: string;
373-
url?: string;
374-
feed_url?: string;
375+
resultCount: number;
376+
results: Array<{
377+
collectionName?: string;
378+
trackName?: string;
379+
artistName?: string;
375380
feedUrl?: string;
376-
website?: string;
381+
artworkUrl600?: string;
382+
artworkUrl100?: string;
383+
collectionViewUrl?: string;
377384
}>;
378385
};
379386

380-
if (!data.success || !Array.isArray(data.data)) {
387+
if (!Array.isArray(data.results)) {
381388
return [];
382389
}
383390

384-
// Filter out results without a valid feed URL and map to our format
385-
// Castos API returns 'url' field for the RSS feed URL
386-
return data.data
387-
.filter(item => {
388-
const feedUrl = item.url ?? item.feed_url ?? item.feedUrl;
389-
return typeof feedUrl === 'string' && feedUrl.length > 0;
390-
})
391+
// Only results with a usable RSS feed URL can be subscribed to.
392+
return data.results
393+
.filter(item => typeof item.feedUrl === 'string' && item.feedUrl.length > 0)
391394
.map(item => ({
392-
title: item.title,
393-
author: item.author ?? null,
394-
description: item.description ?? null,
395-
imageUrl: item.image ?? null,
396-
feedUrl: (item.url ?? item.feed_url ?? item.feedUrl) as string,
397-
websiteUrl: item.website ?? null,
395+
title: item.collectionName ?? item.trackName ?? 'Untitled Podcast',
396+
author: item.artistName ?? null,
397+
description: null,
398+
imageUrl: item.artworkUrl600 ?? item.artworkUrl100 ?? null,
399+
feedUrl: item.feedUrl as string,
400+
websiteUrl: item.collectionViewUrl ?? null,
398401
}));
399402
} catch {
400403
return [];

0 commit comments

Comments
 (0)