import { existsSync } from 'node:fs'; import { castopod } from '$lib/config'; import { fetchText } from './http'; import type { Episode } from '$lib/types'; // Pochette du flux optimisée au prebuild (scripts/fetch-podcast-cover.mjs) const LOCAL_COVER = '/images/podcast-cover.webp'; const hasLocalCover = existsSync(`static${LOCAL_COVER}`); function localEpisodeCover(remoteUrl: string | null): string | null { if (!remoteUrl) return null; const file = remoteUrl.split('/').pop()?.replace(/\.(png|jpe?g|gif|webp)$/i, '.webp'); if (!file) return remoteUrl; const local = `/images/episodes/${file}`; return existsSync(`static${local}`) ? local : remoteUrl; } function tag(xml: string, name: string): string { const cdata = new RegExp(`<${name}[^>]*>`, 'i'); const plain = new RegExp(`<${name}[^>]*>([\\s\\S]*?)`, 'i'); const match = xml.match(cdata) ?? xml.match(plain); return match?.[1]?.trim() ?? ''; } function attr(xml: string, tagName: string, attrName: string): string { const tagMatch = xml.match(new RegExp(`<${tagName}\\b[^>]*>`, 'i')); if (!tagMatch) return ''; const attrMatch = tagMatch[0].match(new RegExp(`${attrName}="([^"]*)"`, 'i')); return attrMatch?.[1] ?? ''; } function stripTags(html: string): string { return html .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/�?39;|'/g, "'") .replace(/\s+/g, ' ') .trim(); } function parseDuration(raw: string): number { if (!raw) return 0; const parts = raw.split(':').map(Number); if (parts.some(Number.isNaN)) return 0; if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; if (parts.length === 2) return parts[0] * 60 + parts[1]; return parts[0] ?? 0; } async function getEpisodesForSlug(slug: string): Promise { const feedUrl = `${castopod.url}/@${slug}/feed`; const xml = await fetchText(feedUrl); if (!xml) return []; const channelImage = tag(xml.split(']/i).slice(1); return items.map((item, index) => { const body = item.split('')[0] ?? item; const title = tag(body, 'title'); const link = tag(body, 'link'); const guid = tag(body, 'guid') || link || `${slug}-${index}`; const description = stripTags(tag(body, 'description') || tag(body, 'itunes:summary')); const remoteImage = attr(body, 'itunes:image', 'href') || channelImage || null; // Les pochettes du flux (souvent lourdes) sont remplacées par leurs versions optimisées au build const image = hasLocalCover && remoteImage === channelImage ? LOCAL_COVER : localEpisodeCover(remoteImage); return { id: guid, title, description, audioUrl: attr(body, 'enclosure', 'url'), audioType: attr(body, 'enclosure', 'type') || 'audio/mpeg', duration: parseDuration(tag(body, 'itunes:duration')), date: tag(body, 'pubDate'), image, link, podcast: slug }; }); } export async function getCastopodEpisodes(count = castopod.episodesCount): Promise { const all: Episode[] = []; for (const slug of castopod.podcastSlugs) { const episodes = await getEpisodesForSlug(slug); all.push(...episodes); if (castopod.podcastSlugs.length > 1) { await new Promise((resolve) => setTimeout(resolve, 5000)); } } return all .filter((episode) => episode.audioUrl) .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) .slice(0, count); }