diff --git a/backend/src/controllers/podcastController.ts b/backend/src/controllers/podcastController.ts index 5d98c44..4148220 100644 --- a/backend/src/controllers/podcastController.ts +++ b/backend/src/controllers/podcastController.ts @@ -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' }); } }; diff --git a/backend/src/services/podcastIndexService.ts b/backend/src/services/podcastIndexService.ts index 979800c..3b27029 100644 --- a/backend/src/services/podcastIndexService.ts +++ b/backend/src/services/podcastIndexService.ts @@ -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; - 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 => { - try { - if (!query || query.trim().length < 2) { - throw new Error('Search query too short (minimum 2 characters)'); + const response = await axios.get( + 'https://itunes.apple.com/search', + { + params: { + term: query, + media: 'podcast', + entity: 'podcast', + limit: Math.min(limit, 50), + }, + timeout: 10000, } + ); - const response = await axios.get( - `${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 => { - try { - const response = await axios.get( - `${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 => { - try { - const response = await axios.get( - `${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 => { - try { - const response = await axios.get( - `${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( + '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, }; diff --git a/frontend/src/pages/Discover.tsx b/frontend/src/pages/Discover.tsx index 3444fb5..e7a93fb 100644 --- a/frontend/src/pages/Discover.tsx +++ b/frontend/src/pages/Discover.tsx @@ -268,12 +268,12 @@ export default function Discover() {

Propulsé par{' '} - Podcast Index + iTunes Search API

diff --git a/frontend/src/services/discoveryService.ts b/frontend/src/services/discoveryService.ts index 596c833..b021775 100644 --- a/frontend/src/services/discoveryService.ts +++ b/frontend/src/services/discoveryService.ts @@ -12,7 +12,7 @@ export interface DiscoveryPodcast { } export interface DiscoveryResponse { - source: 'podcastindex' | 'local'; + source: 'itunes' | 'podcastindex' | 'local'; podcasts: DiscoveryPodcast[]; count: number; message?: string;