mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-11 17:26:20 +02:00
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:
1 parent
9a3171f640
commit
59144e9ce6
4 files changed
+80
-227
No files matched your search
@@ -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' });
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface DiscoveryPodcast {
|
||||
}
|
||||
|
||||
export interface DiscoveryResponse {
|
||||
source: 'podcastindex' | 'local';
|
||||
source: 'itunes' | 'podcastindex' | 'local';
|
||||
podcasts: DiscoveryPodcast[];
|
||||
count: number;
|
||||
message?: string;
|
||||
|
||||
Reference in new issue
Block a user