Files
annu-kute-ced/includes/lib/castopod.php
T

245 lines
10 KiB
PHP

<?php
/**
* Intégration Castopod : lecture des feeds RSS des podcasts.
*
* Extrait de includes/config.php (ARC-1) — chargé par ce dernier.
* Dépend de lib/http.php (client cURL) et lib/format.php (formatDuration).
*/
/**
* Récupère les derniers épisodes d'un ou plusieurs podcasts Castopod via leurs feeds RSS
*
* @param string $castopodUrl URL de l'instance Castopod
* @param array|string $podcastSlugs Slug(s) du/des podcast(s) - tableau ou chaîne unique
* @param int $count Nombre d'épisodes à récupérer (total)
* @return array Liste des épisodes formatés, triés par date
*/
function getCastopodEpisodes($castopodUrl = null, $podcastSlugs = null, $count = 5) {
if (!defined('CASTOPOD_ENABLED') || !CASTOPOD_ENABLED) {
return [];
}
$castopodUrl = $castopodUrl ?? CASTOPOD_URL;
$podcastSlugs = $podcastSlugs ?? (defined('CASTOPOD_PODCAST_SLUGS') ? CASTOPOD_PODCAST_SLUGS : ['annu_kute_cedric']);
$count = $count ?? CASTOPOD_EPISODES_COUNT;
// Validation de l'URL Castopod pour prévenir SSRF
if (!isValidRemoteUrl($castopodUrl)) {
error_log('SECURITY: Invalid Castopod URL detected: ' . $castopodUrl);
return [];
}
// Convertir en tableau si c'est une chaîne unique (rétrocompatibilité)
if (is_string($podcastSlugs)) {
$podcastSlugs = [$podcastSlugs];
}
// Clé de cache unique pour la combinaison de podcasts
$cacheKey = 'castopod_' . md5($castopodUrl . implode('_', $podcastSlugs) . '_' . $count);
// Vérifier le cache
if (defined('CACHE_ENABLED') && CACHE_ENABLED) {
$cachedData = getFromCache($cacheKey);
if ($cachedData !== null) {
return $cachedData;
}
}
$allEpisodes = [];
// Itérer sur chaque podcast
foreach ($podcastSlugs as $index => $podcastSlug) {
try {
// Construire l'URL du feed RSS pour ce podcast
$feedUrl = rtrim($castopodUrl, '/') . '/@' . $podcastSlug . '/feed';
// Récupérer le feed RSS avec backoff uniquement sur rate limit (HTTP 429)
$maxAttempts = 3;
$attempt = 0;
$xmlContent = false;
$httpCode = 0;
while ($attempt < $maxAttempts) {
$response = httpGet($feedUrl, ['timeout' => 10]);
$xmlContent = $response['body'];
$httpCode = $response['code'];
// Succès ou erreur définitive : on sort de la boucle
if ($httpCode !== 429) {
break;
}
// Rate limit : attendre avant de réessayer (backoff exponentiel simple)
$attempt++;
if ($attempt < $maxAttempts) {
sleep(5 * $attempt);
}
}
if ($httpCode !== 200 || !$xmlContent) {
continue; // Passer au podcast suivant en cas d'erreur
}
$episodes = [];
// Vérifier si SimpleXML est disponible
if (function_exists('simplexml_load_string')) {
// Parser avec SimpleXML (méthode recommandée)
$xml = simplexml_load_string($xmlContent);
if (!$xml) {
continue; // Passer au podcast suivant
}
// Enregistrer les namespaces
$namespaces = $xml->getNamespaces(true);
// Extraire les informations du podcast
$podcastTitle = (string) $xml->channel->title;
$podcastImage = (string) $xml->channel->image->url;
$podcastLink = (string) $xml->channel->link;
// Parcourir les items (épisodes) - on récupère tous pour trier après
foreach ($xml->channel->item as $item) {
// Extraire les infos de l'épisode
$itunesNs = $item->children($namespaces['itunes'] ?? 'http://www.itunes.com/dtds/podcast-1.0.dtd');
// Récupérer l'image de l'épisode
$episodeImage = $podcastImage;
if (isset($itunesNs->image)) {
$imageAttrs = $itunesNs->image->attributes();
if (isset($imageAttrs['href'])) {
$episodeImage = (string) $imageAttrs['href'];
}
}
$episode = [
'title' => (string) $item->title,
'link' => (string) $item->link,
'pubDate' => (string) $item->pubDate,
'description' => strip_tags((string) $item->description),
'duration' => (string) $itunesNs->duration ?? '',
'image' => $episodeImage,
'audioUrl' => (string) $item->enclosure['url'] ?? '',
'podcastTitle' => $podcastTitle,
'podcastLink' => $podcastLink,
'podcastSlug' => $podcastSlug,
];
// Formater la date
if ($episode['pubDate']) {
try {
$date = new DateTime($episode['pubDate']);
$episode['formattedDate'] = $date->format('d/m/Y');
$episode['timestamp'] = $date->getTimestamp();
} catch (Exception $e) {
$episode['formattedDate'] = '';
$episode['timestamp'] = 0;
}
}
// Formater la durée
if ($episode['duration']) {
$episode['formattedDuration'] = formatDuration($episode['duration']);
} else {
$episode['formattedDuration'] = '';
}
// Limiter la description
if (strlen($episode['description']) > 150) {
$episode['description'] = substr($episode['description'], 0, 150) . '...';
}
$episodes[] = $episode;
}
} else {
// Fallback: Parser avec regex (moins fiable mais fonctionne sans SimpleXML)
// Extraire les infos du podcast
preg_match('/<channel>.*?<title>(.*?)<\/title>/s', $xmlContent, $podcastTitleMatch);
preg_match('/<channel>.*?<link>(.*?)<\/link>/s', $xmlContent, $podcastLinkMatch);
preg_match('/<image>.*?<url>(.*?)<\/url>/s', $xmlContent, $podcastImageMatch);
$podcastTitle = $podcastTitleMatch[1] ?? '';
$podcastLink = $podcastLinkMatch[1] ?? '';
$podcastImage = $podcastImageMatch[1] ?? '';
// Extraire tous les items
preg_match_all('/<item>(.*?)<\/item>/s', $xmlContent, $items);
foreach ($items[1] as $itemContent) {
// Extraire les données de chaque item
preg_match('/<title>(.*?)<\/title>/', $itemContent, $titleMatch);
preg_match('/<link>(.*?)<\/link>/', $itemContent, $linkMatch);
preg_match('/<pubDate>(.*?)<\/pubDate>/', $itemContent, $dateMatch);
preg_match('/<description>(.*?)<\/description>/s', $itemContent, $descMatch);
preg_match('/<itunes:duration>(.*?)<\/itunes:duration>/', $itemContent, $durationMatch);
preg_match('/<itunes:image[^>]*href=["\']([^"\']*)["\']/', $itemContent, $imageMatch);
preg_match('/<enclosure[^>]*url=["\']([^"\']*)["\']/', $itemContent, $audioMatch);
$episode = [
'title' => $titleMatch[1] ?? '',
'link' => $linkMatch[1] ?? '',
'pubDate' => $dateMatch[1] ?? '',
'description' => strip_tags($descMatch[1] ?? ''),
'duration' => $durationMatch[1] ?? '',
'image' => $imageMatch[1] ?? $podcastImage,
'audioUrl' => $audioMatch[1] ?? '',
'podcastTitle' => $podcastTitle,
'podcastLink' => $podcastLink,
'podcastSlug' => $podcastSlug,
];
// Formater la date
if ($episode['pubDate']) {
try {
$date = new DateTime($episode['pubDate']);
$episode['formattedDate'] = $date->format('d/m/Y');
$episode['timestamp'] = $date->getTimestamp();
} catch (Exception $e) {
$episode['formattedDate'] = '';
$episode['timestamp'] = 0;
}
}
// Formater la durée
if ($episode['duration']) {
$episode['formattedDuration'] = formatDuration($episode['duration']);
} else {
$episode['formattedDuration'] = '';
}
// Limiter la description
if (strlen($episode['description']) > 150) {
$episode['description'] = substr($episode['description'], 0, 150) . '...';
}
$episodes[] = $episode;
}
}
// Ajouter les épisodes de ce podcast à la liste globale
$allEpisodes = array_merge($allEpisodes, $episodes);
} catch (Exception $e) {
error_log('Erreur lors de la récupération des épisodes Castopod pour ' . $podcastSlug . ': ' . $e->getMessage());
// Continuer avec le podcast suivant
}
}
// Trier tous les épisodes par date (du plus récent au plus ancien)
usort($allEpisodes, function($a, $b) {
return ($b['timestamp'] ?? 0) - ($a['timestamp'] ?? 0);
});
// Limiter au nombre demandé
$allEpisodes = array_slice($allEpisodes, 0, $count);
// Mettre en cache (uniquement si non vide : une erreur temporaire
// — rate limit HTTP 429, timeout… — ne doit pas être figée en cache)
if (!empty($allEpisodes) && defined('CACHE_ENABLED') && CACHE_ENABLED) {
saveToCache($cacheKey, $allEpisodes);
}
return $allEpisodes;
}