From 0319fa20c3a1c07467f1d85dd7a144dd7794d872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20FAMIBELLE-PRONZOLA?= Date: Fri, 17 Oct 2025 17:38:16 +0400 Subject: [PATCH] feat: add last podcast from castopod --- README.adoc | 100 ++++++++++++++ css/castopod-podcasts.css | 222 ++++++++++++++++++++++++++++++ css/styles.css | 43 ++---- css/wordpress-posts.css | 8 +- includes/config.default.php | 20 +++ includes/config.local.php.sample | 27 ++++ includes/config.php | 224 +++++++++++++++++++++++++++++++ includes/simple-cache.php | 20 +++ index.php | 115 +++++++++------- 9 files changed, 692 insertions(+), 87 deletions(-) create mode 100644 css/castopod-podcasts.css diff --git a/README.adoc b/README.adoc index ded2fc6..766b1f1 100644 --- a/README.adoc +++ b/README.adoc @@ -24,6 +24,7 @@ - 📡 Détection automatique d'état de connexion - 📊 Analytics intégré avec Plausible (respectueux de la vie privée) - 📰 *Intégration WordPress* : Affichage des articles depuis un site WordPress via REST API +- 🎙️ *Intégration Castopod* : Affichage des derniers épisodes de podcast via flux RSS - 💝 *Système de dons* : Support multi-plateformes (LiberaPay, Ko-fi, Stripe) avec dons ponctuels et récurrents - ⏰ *Système de countdown* : Page de lancement configurable avec compte à rebours multi-fuseaux - 📺 *Annonce du prochain live* : Affichage dynamique avec multi-fuseaux horaires et image personnalisable @@ -88,6 +89,7 @@ * `json` - Pour le traitement des données JSON * `intl` - Pour la gestion des dates internationales et traductions * `mbstring` - Pour la manipulation des chaînes multi-octets + * `xml` (SimpleXML) - Recommandée pour Castopod (fallback regex disponible) - 🌐 *Serveur web* : Apache ou Nginx - 🔒 *HTTPS* : Requis pour les fonctionnalités PWA @@ -480,6 +482,104 @@ Le système intègre plusieurs protections : - ✅ *Ouverture sécurisée* : Liens avec `noopener noreferrer` - ✅ *Tokens CSRF* : Protection contre les attaques CSRF +== 🎙️ Intégration Castopod (Podcasts) + +FEDIVERSE OKI peut afficher les derniers épisodes d'un podcast Castopod directement sur la page d'accueil, à côté de la timeline Mastodon et des articles WordPress. + +=== ✨ Fonctionnalités + +- 🎧 *Affichage des derniers épisodes* : Liste configurable des épisodes récents +- 🖼️ *Miniatures des épisodes* : Affichage des images de couverture +- ⏱️ *Durée et date* : Informations complètes sur chaque épisode +- 🎨 *Design cohérent* : Interface harmonisée avec le reste du site +- 📱 *Responsive* : Affichage adapté mobile et desktop +- 🌙 *Mode sombre* : Support complet du thème sombre +- 🔄 *Fallback intelligent* : Fonctionne avec ou sans l'extension PHP SimpleXML + +=== ⚙️ Configuration + +Pour activer l'intégration Castopod, ajoutez dans votre `config.local.php` : + +[source,php] +---- +// Activer l'affichage des podcasts Castopod +define('CASTOPOD_ENABLED', true); + +// URL de l'instance Castopod +define('CASTOPOD_URL', 'https://kute.o-k-i.net'); + +// Liste des slugs de podcasts à afficher +// Les épisodes de tous les podcasts seront mélangés et triés par date +define('CASTOPOD_PODCAST_SLUGS', [ + 'joukawouve', + 'cspcc', + 'radyobokaz' +]); + +// Nombre d'épisodes à afficher (total, tous podcasts confondus) +define('CASTOPOD_EPISODES_COUNT', 5); + +// IMPORTANT : Activer le cache pour éviter le rate limiting +define('CACHE_ENABLED', true); +define('CACHE_DURATION', 3600); // 1 heure recommandé +---- + +WARNING: *Rate Limiting* : Castopod applique un rate limit strict sur les requêtes RSS. Pour afficher plusieurs podcasts, le système ajoute un délai de 5 secondes entre chaque requête. *Le cache est fortement recommandé* pour éviter ces délais à chaque chargement de page. + +TIP: Pour un seul podcast, vous pouvez utiliser une chaîne : `define('CASTOPOD_PODCAST_SLUGS', ['joukawouve']);` + +=== 📋 Prérequis + +- *Extension PHP XML* (recommandée mais optionnelle) : ++ +[source,bash] +---- +# Sur Ubuntu/Debian +sudo apt install php8.3-xml + +# Sur CentOS/RHEL +sudo yum install php-xml +---- ++ +NOTE: Si l'extension n'est pas installée, un fallback avec parsing regex sera utilisé automatiquement (moins performant mais fonctionnel). + +=== 🎯 Affichage + +La section Castopod s'affiche automatiquement sur la page d'accueil selon la configuration : + +*Ordre d'affichage* : +1. Vidéo hero (si configurée) +2. Podcasts Castopod +3. Timeline Mastodon +4. Articles WordPress (si activé) + +*Desktop* : +- Castopod + Mastodon : Layout 1fr 2fr (podcasts étroit, timeline large) +- Castopod + Mastodon + WordPress : Layout 1fr 2fr 1fr (trois colonnes) +- Mastodon + WordPress (sans Castopod) : Layout 2fr 1fr (timeline large, articles étroits) + +*Mobile/Tablette* : +- Layout vertical avec toutes les sections empilées dans l'ordre ci-dessus + +=== 🔧 Fonctionnement technique + +Le système récupère les épisodes via le flux RSS Castopod : + +- *URL du feed* : `CASTOPOD_URL/@PODCAST_SLUG/feed` +- *Format* : RSS 2.0 avec namespace iTunes +- *Données extraites* : Titre, lien, date, durée, image, description +- *Cache* : Support du système de cache si activé +- *Timeout* : 10 secondes pour éviter les blocages + +=== 🎨 Personnalisation CSS + +Les styles sont définis dans `css/castopod-podcasts.css` : + +- `.castopod-container` : Conteneur principal +- `.castopod-episode-item` : Élément d'épisode +- `.castopod-episode-thumb` : Miniature avec effet hover +- `.castopod-play-icon` : Icône de lecture au survol + == 🛡️ Configuration de sécurité Apache Le fichier `conf/.htaccess.sample` fourni inclut des règles de sécurité importantes pour protéger votre installation : diff --git a/css/castopod-podcasts.css b/css/castopod-podcasts.css new file mode 100644 index 0000000..e01c0eb --- /dev/null +++ b/css/castopod-podcasts.css @@ -0,0 +1,222 @@ +/* Styles pour la liste compacte de podcasts Castopod */ + +.castopod-container { + background: var(--card-bg); + border-radius: 12px; + border: 1px solid var(--border-color); + box-shadow: var(--card-shadow); + overflow: hidden; + height: 400px; + display: flex; + flex-direction: column; +} + +.castopod-section { + width: 100%; + min-width: 0; + margin-bottom: 1em; +} + +.castopod-header { + background: var(--primary-red); + color: white; + padding: 12px 16px; + font-size: 1rem; + font-weight: bold; + display: flex; + align-items: center; + gap: 8px; +} + +.castopod-header a { + color: white; + text-decoration: none; + transition: opacity 0.2s ease; +} + +.castopod-header a:hover { + opacity: 0.8; + text-decoration: underline; +} + +.castopod-episodes-list { + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--primary-red) transparent; +} + +.castopod-episodes-list::-webkit-scrollbar { + width: 6px; +} + +.castopod-episodes-list::-webkit-scrollbar-track { + background: transparent; +} + +.castopod-episodes-list::-webkit-scrollbar-thumb { + background-color: var(--primary-red); + border-radius: 3px; +} + +.castopod-episode-item { + display: flex; + padding: 8px 12px; + border-bottom: 1px solid var(--border-color); + text-decoration: none; + color: inherit; + transition: background-color 0.2s ease; + gap: 8px; +} + +.castopod-episode-item:hover { + background: var(--hover-bg); + text-decoration: none; + color: inherit; +} + +.castopod-episode-item:last-child { + border-bottom: none; +} + +.castopod-episode-thumb { + width: 60px; + height: 60px; + border-radius: 8px; + overflow: hidden; + flex-shrink: 0; + background: var(--tag-bg); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + position: relative; +} + +.castopod-episode-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.castopod-play-icon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 1.5rem; + color: rgba(255, 255, 255, 0.9); + text-shadow: 0 0 10px rgba(0, 0, 0, 0.8); + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.castopod-episode-item:hover .castopod-play-icon { + opacity: 1; +} + +.castopod-episode-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; +} + +.castopod-episode-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-color); + margin: 0 0 4px 0; + line-height: 1.2; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.castopod-podcast-badge { + font-size: 0.65rem; + color: var(--primary-red); + font-weight: 600; + margin: 0 0 4px 0; + display: flex; + align-items: center; + gap: 4px; +} + +.castopod-podcast-badge i { + font-size: 0.6rem; +} + +.castopod-episode-meta { + font-size: 0.65rem; + color: var(--text-secondary); + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; +} + +.castopod-episode-meta i { + font-size: 0.6rem; +} + +.castopod-no-episodes { + text-align: center; + color: var(--text-secondary); + padding: 20px; + font-style: italic; + font-size: 0.9rem; +} + +/* Ajustement du layout selon les contenus présents */ +/* Ordre dans le HTML : 1. Castopod, 2. Mastodon, 3. WordPress */ + + +/* Responsive Design */ +@media (max-width: 1024px) { + /* Mobile/Tablette: layout vertical */ + .timeline-container:has(.castopod-section) { + grid-template-columns: 1fr !important; + } + + .castopod-section { + width: 100%; + } + + .castopod-container { + height: auto; + max-height: 400px; + } +} + +@media (max-width: 768px) { + .castopod-episode-thumb { + width: 65px; + height: 65px; + } + + .castopod-episode-item { + padding: 10px 12px; + gap: 10px; + } + + .castopod-episode-title { + font-size: 0.85rem; + } + + .castopod-episode-meta { + font-size: 0.65rem; + } + + .castopod-header { + padding: 10px 12px; + font-size: 0.9rem; + } + + .castopod-play-icon { + font-size: 1.3rem; + } +} diff --git a/css/styles.css b/css/styles.css index 01f8682..99d97e2 100644 --- a/css/styles.css +++ b/css/styles.css @@ -594,45 +594,24 @@ img { align-items: start; } -/* Quand pas de hero (HERO_TYPE = 'none') et pas de WordPress: centrer la timeline Mastodon */ -.hero-mastodon-wrapper:has(.timeline-wordpress-container:only-child) .timeline-wordpress-container:not(:has(.wordpress-section)) { - grid-column: 1 / -1; - display: flex; - justify-content: center; -} - -.hero-mastodon-wrapper:has(.timeline-wordpress-container:only-child) .timeline-wordpress-container:not(:has(.wordpress-section)) #mt-container { +.timeline-container { max-width: 800px; width: 100%; + justify-self: center; } + /* Responsive breakpoint pour écrans entre 1025px et 1699px */ @media (max-width: 1699px) and (min-width: 1025px) { .hero-mastodon-wrapper { display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 20px; - } - - .hero { - grid-column: 1 / -1; - width: 100%; - } - - .timeline-wordpress-container { - grid-column: 1 / -1; - display: grid; - grid-template-columns: repeat(2, 1fr); + grid-template-columns: 1fr 1fr; gap: 20px; } #mt-container { grid-column: 1 / 2; } - - .wordpress-section { - grid-column: 2 / 3; - } } .hero { @@ -1032,6 +1011,7 @@ img { border-radius: 8px; display: flex; flex-direction: column; + margin-bottom: 1em; } .mt-header { @@ -2309,7 +2289,7 @@ img { } /* Pour le nouveau layout timeline-wordpress */ - .timeline-wordpress-container #mt-container { + .timeline-container #mt-container { height: 400px; } @@ -2855,17 +2835,16 @@ i.icon-mastodon, display: block; } + .timeline-container { + width: 100%; + max-width: 100%; + } + .hero { width: 100%; margin-bottom: 20px; } - .timeline-wordpress-container { - display: flex; - flex-direction: row; - gap: 20px; - } - #mt-container { flex: 1; } diff --git a/css/wordpress-posts.css b/css/wordpress-posts.css index 27faf5c..9738299 100644 --- a/css/wordpress-posts.css +++ b/css/wordpress-posts.css @@ -1,7 +1,7 @@ /* Styles pour la liste compacte d'articles WordPress */ /* Container principal pour timeline + wordpress */ -.timeline-wordpress-container { +.timeline-container { display: grid; grid-template-columns: 2fr 1fr; gap: 20px; @@ -27,7 +27,7 @@ } /* Assurer que le container Mastodon garde son scroll */ -.timeline-wordpress-container .mt-container { +.timeline-container .mt-container { height: 400px !important; overflow-y: auto !important; } @@ -155,7 +155,7 @@ /* Responsive Design */ @media (max-width: 1024px) { /* Mobile/Tablette: layout vertical */ - .timeline-wordpress-container { + .timeline-container { grid-template-columns: 1fr; gap: 15px; } @@ -165,7 +165,7 @@ } /* Maintenir la hauteur et le scroll du container Mastodon en mobile */ - .timeline-wordpress-container .mt-container { + .timeline-container .mt-container { height: 400px !important; overflow-y: auto !important; max-height: 400px !important; diff --git a/includes/config.default.php b/includes/config.default.php index e7a8c2b..07e9609 100644 --- a/includes/config.default.php +++ b/includes/config.default.php @@ -134,6 +134,26 @@ if (!defined('PLAYLIST_TITLE')) define('PLAYLIST_TITLE', ''); // Description de la playlist (optionnel) if (!defined('PLAYLIST_DESCRIPTION')) define('PLAYLIST_DESCRIPTION', ''); +// ========================================= +// Configuration Castopod +// ========================================= + +// Activer l'affichage des podcasts Castopod +if (!defined('CASTOPOD_ENABLED')) define('CASTOPOD_ENABLED', true); + +// URL de l'instance Castopod +if (!defined('CASTOPOD_URL')) define('CASTOPOD_URL', 'https://kute.o-k-i.net'); + +// Liste des slugs de podcasts à afficher (tableau) +// Format: ['slug1', 'slug2', 'slug3'] +// Les épisodes de tous les podcasts seront mélangés et triés par date +if (!defined('CASTOPOD_PODCAST_SLUGS')) { + define('CASTOPOD_PODCAST_SLUGS', ['joukawouve']); +} + +// Nombre d'épisodes à afficher (total, tous podcasts confondus) +if (!defined('CASTOPOD_EPISODES_COUNT')) define('CASTOPOD_EPISODES_COUNT', 5); + // Tags pour filtrer les vidéos selon les catégories if (!defined('TAG_SHORT')) define('TAG_SHORT', 'short'); diff --git a/includes/config.local.php.sample b/includes/config.local.php.sample index 8901262..93016ca 100644 --- a/includes/config.local.php.sample +++ b/includes/config.local.php.sample @@ -306,6 +306,33 @@ define('WORDPRESS_ENABLED', false); // URL du site WordPress pour récupérer les articles (sans trailing slash) // define('WORDPRESS_URL', 'https://votre-site-wordpress.com'); +// ========================================= +// Intégration Castopod (Podcasts) +// ========================================= + +// Activer/désactiver l'affichage des podcasts Castopod +// define('CASTOPOD_ENABLED', true); + +// URL de l'instance Castopod +// define('CASTOPOD_URL', 'https://kute.o-k-i.net'); + +// Liste des slugs de podcasts à afficher (tableau) +// Les épisodes de tous les podcasts seront mélangés et triés par date +// define('CASTOPOD_PODCAST_SLUGS', [ +// 'joukawouve', +// 'cspcc', +// 'radyobokaz' +// ]); + +// Nombre d'épisodes à afficher (total, tous podcasts confondus) +// define('CASTOPOD_EPISODES_COUNT', 5); + +// IMPORTANT : Le cache est fortement recommandé pour éviter le rate limiting de Castopod +// Castopod applique un rate limit strict (HTTP 429) sur les requêtes RSS +// Avec le cache activé, les données sont mises en cache pendant CACHE_DURATION secondes +// define('CACHE_ENABLED', true); +// define('CACHE_DURATION', 3600); // 1 heure recommandé + // ========================================= // Système de dons // ========================================= diff --git a/includes/config.php b/includes/config.php index 67674ab..ce2894b 100644 --- a/includes/config.php +++ b/includes/config.php @@ -649,4 +649,228 @@ function searchVideos($query, $count = COUNT_VIDEO_SEARCH, $start = 0) { ]); return formatVideosData($data['data'] ?? []); } + +/** + * 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 : ['joukawouve']); + $count = $count ?? CASTOPOD_EPISODES_COUNT; + + // 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 { + // Ajouter un délai entre les requêtes pour éviter le rate limiting (sauf pour la première) + // Note: Castopod a un rate limit strict, le cache est fortement recommandé + if ($index > 0) { + sleep(5); // 5 secondes de délai minimum pour Castopod + } + + // Construire l'URL du feed RSS pour ce podcast + $feedUrl = rtrim($castopodUrl, '/') . '/@' . $podcastSlug . '/feed'; + + // Récupérer le feed RSS + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $feedUrl); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + $xmlContent = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + 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('/.*?(.*?)<\/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 + if (defined('CACHE_ENABLED') && CACHE_ENABLED) { + saveToCache($cacheKey, $allEpisodes); + } + + return $allEpisodes; +} ?> diff --git a/includes/simple-cache.php b/includes/simple-cache.php index 897adf2..0112596 100644 --- a/includes/simple-cache.php +++ b/includes/simple-cache.php @@ -160,6 +160,26 @@ function callApiCached($cacheKey, $callable, $ttl = 900) { return $data; } +/** + * Helpers simples pour le cache générique + */ +function getFromCache($key) { + if (!defined('CACHE_ENABLED') || !CACHE_ENABLED) { + return null; + } + $cache = $GLOBALS['simple_api_cache']; + return $cache->get($key, []); +} + +function saveToCache($key, $data, $ttl = null) { + if (!defined('CACHE_ENABLED') || !CACHE_ENABLED) { + return; + } + $ttl = $ttl ?? (defined('CACHE_DURATION') ? CACHE_DURATION : 3600); + $cache = $GLOBALS['simple_api_cache']; + $cache->set($key, [], $data, $ttl); +} + // Nettoyage automatique occasionnel if (rand(1, 100) === 1) { $GLOBALS['simple_api_cache']->cleanup(); diff --git a/index.php b/index.php index 4cb6c8f..24b715a 100644 --- a/index.php +++ b/index.php @@ -27,6 +27,9 @@ setSecurityHeaders(); <?php if (defined('WORDPRESS_ENABLED') && WORDPRESS_ENABLED): ?> <link rel="stylesheet" href="css/wordpress-posts.css?v=<?php echo filemtime('css/wordpress-posts.css'); ?>"> <?php endif; ?> + <?php if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED): ?> + <link rel="stylesheet" href="css/castopod-podcasts.css?v=<?php echo filemtime('css/castopod-podcasts.css'); ?>"> + <?php endif; ?> <!-- Favicons --> <link rel="apple-touch-icon" sizes="180x180" href="img/apple-touch-icon.png"> @@ -104,67 +107,66 @@ setSecurityHeaders(); <!-- Hero Banner --> <?php include 'includes/hero-section.php'; ?> - <!-- Container pour Mastodon et WordPress côte à côte --> - <div class="timeline-wordpress-container"> - <div id="mt-container" class="mt-container"> - <div class="mt-header"> - <h2 class="mt-title">Fil d'actualités <i class="fab fa-mastodon"></i></h2> - </div> - <div class="mt-body" role="feed"> - <div class="mt-loading-spinner"></div> - </div> - </div> - - <!-- Section Articles WordPress (desktop à droite, mobile en dessous) --> - <?php if (defined('WORDPRESS_ENABLED') && WORDPRESS_ENABLED && !empty(WORDPRESS_URL)): ?> - <div class="wordpress-section"> - <div class="wordpress-container"> - <div class="wordpress-header"> - <i class="fas fa-newspaper"></i> - Articles <?php if (!empty(WORDPRESS_URL)): ?> - <a href="<?php echo htmlspecialchars(WORDPRESS_URL); ?>" - target="_blank" - rel="noopener noreferrer" - title="Visiter <?php echo htmlspecialchars(parse_url(WORDPRESS_URL, PHP_URL_HOST)); ?>"> - <?php echo htmlspecialchars(parse_url(WORDPRESS_URL, PHP_URL_HOST)); ?> + <!-- Section Podcasts Castopod --> + <?php if (defined('CASTOPOD_ENABLED') && CASTOPOD_ENABLED && defined('CASTOPOD_URL') && !empty(CASTOPOD_URL)): ?> + <div class="castopod-section"> + <div class="castopod-container"> + <div class="castopod-header"> + <i class="fas fa-podcast"></i> + Podcasts + <?php if (!empty(CASTOPOD_URL)): ?> + <a href="<?php echo htmlspecialchars(CASTOPOD_URL); ?>" + target="_blank" + rel="noopener noreferrer" + title="Visiter <?php echo htmlspecialchars(parse_url(CASTOPOD_URL, PHP_URL_HOST)); ?>"> + <?php echo htmlspecialchars(parse_url(CASTOPOD_URL, PHP_URL_HOST)); ?> </a> <?php endif; ?> </div> - <div class="wordpress-posts-list"> + <div class="castopod-episodes-list"> <?php - $wordpressPosts = getWordPressPosts(); - - if (empty($wordpressPosts)) { - echo '<div class="wordpress-no-posts">Aucun article disponible</div>'; + $castopodEpisodes = getCastopodEpisodes(); + + if (empty($castopodEpisodes)) { + echo '<div class="castopod-no-episodes">Aucun épisode disponible</div>'; } else { - foreach ($wordpressPosts as $post): + foreach ($castopodEpisodes as $episode): ?> - <a href="<?php echo htmlspecialchars($post['link']); ?>" - class="wordpress-post-item" - target="_blank" - rel="noopener noreferrer" - title="<?php echo htmlspecialchars($post['title']); ?>"> - - <div class="wordpress-post-thumb"> - <?php if (!empty($post['featured_image'])): ?> - <img src="<?php echo htmlspecialchars($post['featured_image']); ?>" - alt="<?php echo htmlspecialchars($post['title']); ?>" - loading="lazy"> + <a href="<?php echo htmlspecialchars($episode['link']); ?>" + class="castopod-episode-item" + target="_blank" + rel="noopener noreferrer" + title="<?php echo htmlspecialchars($episode['title']); ?>"> + + <div class="castopod-episode-thumb"> + <?php if (!empty($episode['image'])): ?> + <img src="<?php echo htmlspecialchars($episode['image']); ?>" + alt="<?php echo htmlspecialchars($episode['title']); ?>" + loading="lazy"> + <div class="castopod-play-icon"> + <i class="fas fa-play-circle"></i> + </div> <?php else: ?> - <i class="fas fa-file-alt"></i> + <i class="fas fa-podcast"></i> <?php endif; ?> </div> - - <div class="wordpress-post-info"> - <h3 class="wordpress-post-title"> - <?php echo htmlspecialchars($post['title']); ?> + + <div class="castopod-episode-info"> + <h3 class="castopod-episode-title"> + <?php echo htmlspecialchars($episode['title']); ?> </h3> - - <div class="wordpress-post-meta"> - <span><?php echo htmlspecialchars($post['date']); ?></span> - <?php if (!empty($post['author'])): ?> + + <?php if (!empty($episode['podcastTitle'])): ?> + <div class="castopod-podcast-badge"> + <i class="fas fa-podcast"></i> <?php echo htmlspecialchars($episode['podcastTitle']); ?> + </div> + <?php endif; ?> + + <div class="castopod-episode-meta"> + <span><i class="far fa-calendar"></i> <?php echo htmlspecialchars($episode['formattedDate']); ?></span> + <?php if (!empty($episode['formattedDuration'])): ?> <span>•</span> - <span><?php echo htmlspecialchars($post['author']); ?></span> + <span><i class="far fa-clock"></i> <?php echo htmlspecialchars($episode['formattedDuration']); ?></span> <?php endif; ?> </div> </div> @@ -176,7 +178,18 @@ setSecurityHeaders(); </div> </div> </div> - <?php endif; ?> + <?php endif; ?> + + <!-- Container pour Mastodon et Castopod côte à côte --> + <div style="<?php if(defined('HERO_TYPE') && HERO_TYPE !== 'none'): echo 'grid-column: 1 / -1;'; endif; ?>" class="timeline-container"> + <div id="mt-container" class="mt-container"> + <div class="mt-header"> + <h2 class="mt-title">Fil d'actualités <i class="fab fa-mastodon"></i></h2> + </div> + <div class="mt-body" role="feed"> + <div class="mt-loading-spinner"></div> + </div> + </div> </div> </div>