Remplace PodcastIndex par iTunes Search API (gratuit, sans clé)

- Réécriture complète de podcastIndexService avec l'API iTunes d'Apple
- Recherche de podcasts : itunes.apple.com/search (gratuit, pas d'auth)
- Tendances : top podcasts France via rss.applemarketingtools.com + lookup RSS URLs
- Suppression des vérifications isConfigured() dans le contrôleur

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-04-16 11:21:26 +02:00
1 parent 9a3171f640
commit 59144e9ce6
4 files changed
+80 -227

No files matched your search

+8 -33
View File
@@ -173,35 +173,19 @@ export const getPodcast = async (req: Request, res: Response) => {
export const discoverPodcasts = async (req: Request, res: Response) => {
try {
const { q, limit = 20, offset = 0 } = searchSchema.parse(req.query);
const { q, limit = 20 } = searchSchema.parse(req.query);
// If PodcastIndex is not configured, return message
if (!podcastIndexService.isConfigured()) {
// Return empty for now, user can subscribe via RSS URL
return res.json({
source: 'local',
podcasts: [],
count: 0,
message: 'PodcastIndex API not configured - use RSS URL to subscribe',
});
}
// Search via PodcastIndex
const results = await podcastIndexService.searchPodcasts(
q as string,
limit as number,
offset as number
);
const results = await podcastIndexService.searchPodcasts(q as string, limit as number);
res.json({
source: 'podcastindex',
source: 'itunes',
podcasts: results,
count: results.length,
});
} catch (error: any) {
console.error('Discover podcasts error:', error.message);
res.status(error.status || 500).json({
message: error.message || 'Search failed',
message: error.message || 'Recherche échouée',
});
}
};
@@ -278,26 +262,17 @@ export const subscribeFromDiscovery = async (req: Request, res: Response) => {
export const getTrendingPodcasts = async (req: Request, res: Response) => {
try {
const { limit = 20 } = req.query;
const limit = Number(req.query.limit) || 20;
if (!podcastIndexService.isConfigured()) {
return res.json({
source: 'local',
podcasts: [],
count: 0,
message: 'PodcastIndex API not configured',
});
}
const trending = await podcastIndexService.getTrendingPodcasts(limit as number);
const trending = await podcastIndexService.getTrendingPodcasts(limit);
res.json({
source: 'podcastindex',
source: 'itunes',
podcasts: trending,
count: trending.length,
});
} catch (error: any) {
console.error('Trending podcasts error:', error.message);
res.status(500).json({ message: 'Failed to fetch trending podcasts' });
res.status(500).json({ message: 'Impossible de récupérer les tendances' });
}
};
+69 -191
View File
@@ -1,31 +1,36 @@
import axios from 'axios';
import crypto from 'crypto';
/**
* PodcastIndex API Service
* Integrates with Podcast Index API for podcast search and data
* https://podcastindex-api.com/
* iTunes Search API Service - Free, no API key required
* https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/
*/
interface PodcastIndexResponse {
status: string;
feeds: Array<{
id: number;
title: string;
url: string;
description: string;
author?: string;
image?: string;
artwork?: string;
language?: string;
categories?: Record<string, string>;
lastUpdate?: number;
episodeCount?: number;
}>;
count: number;
interface ItunesSearchResult {
collectionId: number;
collectionName: string;
feedUrl?: string;
artworkUrl600?: string;
artworkUrl100?: string;
artistName?: string;
primaryGenreName?: string;
trackCount?: number;
country?: string;
description?: string;
}
interface SearchResult {
interface ItunesSearchResponse {
resultCount: number;
results: ItunesSearchResult[];
}
interface ItunesTopResult {
id: string;
name: string;
artworkUrl100: string;
artistName: string;
}
export interface SearchResult {
id: string;
title: string;
rssUrl: string;
@@ -36,193 +41,66 @@ interface SearchResult {
episodeCount?: number;
}
const API_KEY = process.env.PODCAST_INDEX_API_KEY;
const API_SECRET = process.env.PODCAST_INDEX_API_SECRET;
const API_BASE = 'https://api.podcastindex.org/api/1.0';
// Create auth headers for PodcastIndex API
const getAuthHeaders = () => {
if (!API_KEY || !API_SECRET) {
throw new Error('PodcastIndex API credentials not configured');
}
const timestamp = Math.floor(Date.now() / 1000);
const auth = `${API_KEY}${API_SECRET}${timestamp}`;
const authHash = crypto.createHash('sha1').update(auth).digest('hex');
const mapItunesResult = (item: ItunesSearchResult): SearchResult | null => {
if (!item.feedUrl) return null;
return {
'X-Auth-Date': timestamp.toString(),
'X-Auth-Digest': authHash,
'User-Agent': 'Podcastic (https://podcastic.app)',
id: item.collectionId.toString(),
title: item.collectionName,
rssUrl: item.feedUrl,
description: item.description || item.primaryGenreName || '',
author: item.artistName || '',
imageUrl: item.artworkUrl600 || item.artworkUrl100,
episodeCount: item.trackCount,
};
};
export const podcastIndexService = {
/**
* Search for podcasts by query
*/
searchPodcasts: async (
query: string,
limit: number = 20,
offset: number = 0
): Promise<SearchResult[]> => {
try {
if (!query || query.trim().length < 2) {
throw new Error('Search query too short (minimum 2 characters)');
const response = await axios.get<ItunesSearchResponse>(
'https://itunes.apple.com/search',
{
params: {
term: query,
media: 'podcast',
entity: 'podcast',
limit: Math.min(limit, 50),
},
timeout: 10000,
}
);
const response = await axios.get<PodcastIndexResponse>(
`${API_BASE}/podcasts/search`,
{
params: {
q: query,
limit: Math.min(limit, 50), // Cap at 50
offset,
},
headers: getAuthHeaders(),
timeout: 10000,
}
);
if (!response.data.feeds) {
return [];
}
return response.data.feeds.map((feed) => ({
id: feed.id.toString(),
title: feed.title,
rssUrl: feed.url,
description: feed.description || '',
author: feed.author || '',
imageUrl: feed.artwork || feed.image,
language: feed.language || 'en',
episodeCount: feed.episodeCount,
}));
} catch (error: any) {
console.error('PodcastIndex search error:', error.message);
// Return empty array instead of throwing to graceful degrade
if (error.response?.status === 429) {
throw new Error('Search rate limited. Please try again later.');
}
if (error.response?.status === 401) {
throw new Error('PodcastIndex API authentication failed');
}
throw new Error(`Search failed: ${error.message}`);
}
return response.data.results
.map(mapItunesResult)
.filter((r): r is SearchResult => r !== null);
},
/**
* Get podcast by RSS URL
*/
getPodcastByRssUrl: async (rssUrl: string): Promise<SearchResult | null> => {
try {
const response = await axios.get<PodcastIndexResponse>(
`${API_BASE}/podcasts/byfeedurl`,
{
params: { url: rssUrl },
headers: getAuthHeaders(),
timeout: 10000,
}
);
if (!response.data.feeds || response.data.feeds.length === 0) {
return null;
}
const feed = response.data.feeds[0];
return {
id: feed.id.toString(),
title: feed.title,
rssUrl: feed.url,
description: feed.description || '',
author: feed.author || '',
imageUrl: feed.artwork || feed.image,
language: feed.language || 'en',
episodeCount: feed.episodeCount,
};
} catch (error: any) {
console.error('PodcastIndex lookup error:', error.message);
return null;
}
},
/**
* Search by podcast title
*/
searchByTitle: async (title: string, limit: number = 10): Promise<SearchResult[]> => {
try {
const response = await axios.get<PodcastIndexResponse>(
`${API_BASE}/podcasts/search`,
{
params: {
q: title,
limit: Math.min(limit, 30),
},
headers: getAuthHeaders(),
timeout: 10000,
}
);
if (!response.data.feeds) {
return [];
}
return response.data.feeds.map((feed) => ({
id: feed.id.toString(),
title: feed.title,
rssUrl: feed.url,
description: feed.description || '',
author: feed.author || '',
imageUrl: feed.artwork || feed.image,
language: feed.language || 'en',
episodeCount: feed.episodeCount,
}));
} catch (error: any) {
console.error('Title search error:', error.message);
return [];
}
},
/**
* Get trending podcasts
*/
getTrendingPodcasts: async (limit: number = 20): Promise<SearchResult[]> => {
try {
const response = await axios.get<PodcastIndexResponse>(
`${API_BASE}/podcasts/trending`,
{
params: { limit: Math.min(limit, 50) },
headers: getAuthHeaders(),
timeout: 10000,
}
);
// Get Apple's top podcasts list
const topResponse = await axios.get<{ feed: { results: ItunesTopResult[] } }>(
`https://rss.applemarketingtools.com/api/v2/fr/podcasts/top-podcasts/${Math.min(limit, 50)}/podcasts.json`,
{ timeout: 10000 }
);
if (!response.data.feeds) {
return [];
const topItems = topResponse.data.feed.results;
if (!topItems || topItems.length === 0) return [];
// Batch lookup to get RSS URLs
const ids = topItems.map((item) => item.id).join(',');
const lookupResponse = await axios.get<ItunesSearchResponse>(
'https://itunes.apple.com/lookup',
{
params: { id: ids, entity: 'podcast' },
timeout: 10000,
}
);
return response.data.feeds.map((feed) => ({
id: feed.id.toString(),
title: feed.title,
rssUrl: feed.url,
description: feed.description || '',
author: feed.author || '',
imageUrl: feed.artwork || feed.image,
language: feed.language || 'en',
episodeCount: feed.episodeCount,
}));
} catch (error: any) {
console.error('Trending podcasts error:', error.message);
return [];
}
return lookupResponse.data.results
.map(mapItunesResult)
.filter((r): r is SearchResult => r !== null);
},
/**
* Check if API credentials are configured
*/
isConfigured: (): boolean => {
return !!(API_KEY && API_SECRET);
},
isConfigured: (): boolean => true,
};
+2 -2
View File
@@ -268,12 +268,12 @@ export default function Discover() {
<p>
Propulsé par{' '}
<a
href="https://podcastindex.org"
href="https://www.apple.com/itunes/"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
Podcast Index
iTunes Search API
</a>
</p>
</div>
+1 -1
View File
@@ -12,7 +12,7 @@ export interface DiscoveryPodcast {
}
export interface DiscoveryResponse {
source: 'podcastindex' | 'local';
source: 'itunes' | 'podcastindex' | 'local';
podcasts: DiscoveryPodcast[];
count: number;
message?: string;