feat: implement RSS parsing service and podcast controller for feed ingestion and episode synchronization

This commit is contained in:
Michael committed 2026-04-16 13:55:18 +02:00
1 parent ba376930b0
commit e075434dd7
5 files changed
+102 -16

No files matched your search

@@ -56,6 +56,9 @@ export const subscribe = async (req: Request, res: Response) => {
let podcast = await Podcast.findOne({ rssUrl });
if (!podcast) {
// Parse RSS feed
const feedData = await rssParserService.parseFeed(rssUrl);
const episodes = rssParserService.parseEpisodes(feedData.items, feedData.image?.url || '', rssUrl);
try {
const result = await rssParserService.createPodcastFromRss(rssUrl);
podcast = await Podcast.findById(result.podcast._id);
@@ -146,6 +149,29 @@ export const unsubscribe = async (req: Request, res: Response) => {
}
};
export const syncPodcast = async (req: Request, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({ message: 'Not authenticated' });
}
const { podcastId } = req.params;
const podcast = await Podcast.findById(podcastId);
if (!podcast) {
return res.status(404).json({ message: 'Podcast not found' });
}
console.log(`[Sync] Triggering manual sync for podcast: ${podcast.title} (${podcastId})`);
const result = await rssParserService.syncPodcastEpisodes(podcastId, podcast.rssUrl, true);
res.json(result);
} catch (error: any) {
console.error('Manual sync error:', error);
res.status(500).json({ message: error.message || 'Failed to sync podcast' });
}
};
export const searchPodcasts = async (req: Request, res: Response) => {
try {
const { q, limit = 20, skip = 0 } = req.query;
+2
View File
@@ -3,6 +3,7 @@ import {
getUserSubscriptions,
subscribe,
unsubscribe,
syncPodcast,
searchPodcasts,
getPodcast,
discoverPodcasts,
@@ -18,6 +19,7 @@ router.get('/subscriptions', authenticate, getUserSubscriptions);
router.post('/subscribe', authenticate, subscribe);
router.post('/subscribe-discovery', authenticate, subscribeFromDiscovery);
router.delete('/:podcastId/unsubscribe', authenticate, unsubscribe);
router.post('/:podcastId/sync', authenticate, syncPodcast);
// Public/discovery routes
router.get('/discover', discoverPodcasts);
+28 -12
View File
@@ -138,13 +138,24 @@ export const rssParserService = {
/**
* Parse episodes from RSS feed items
*/
parseEpisodes: (items: any[], podcastImage?: string): ParsedEpisode[] => {
return items
parseEpisodes: (items: any[], podcastImage?: string, rssUrl: string = ''): ParsedEpisode[] => {
const parsed = items
.filter((item) => {
const audioUrl = rssParserService.extractAudioUrl(item);
return !!audioUrl && !!item.title; // Must have both audio and title
})
.map((item) => ({
const hasTitle = !!item.title;
if (!audioUrl || !hasTitle) {
// Log only a sample to avoid flooding, but enough to debug
if (items.indexOf(item) === 0) {
console.log(`[RSS Debug] Item check failed for "${item.title || 'Untitled'}": Audio: ${!!audioUrl}, RSS URL Sample: ${rssUrl.substring(0, 30)}...`);
if (!audioUrl) console.log(`[RSS Debug] Available keys for item: ${Object.keys(item).join(', ')}`);
}
}
return !!audioUrl && hasTitle;
});
return parsed.map((item) => ({
title: item.title || 'Untitled Episode',
description: item.contentSnippet || item.description || '',
audioUrl: rssParserService.extractAudioUrl(item) || '',
@@ -159,15 +170,20 @@ export const rssParserService = {
/**
* Fetch and sync podcast episodes
*/
syncPodcastEpisodes: async (podcastId: string, rssUrl: string) => {
syncPodcastEpisodes: async (podcastId: string, rssUrl: string, force: boolean = false) => {
try {
const redis = getRedis();
// Check cache first
const cacheKey = `podcast:${podcastId}:sync`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
// Check cache unless forced
if (!force) {
const cached = await redis.get(cacheKey);
if (cached) {
console.log(`[RSS Sync] Returning cached result for ${podcastId}`);
return JSON.parse(cached);
}
} else {
console.log(`[RSS Sync] Force sync requested for ${podcastId}`);
}
const podcast = await Podcast.findById(podcastId);
@@ -274,7 +290,7 @@ export const rssParserService = {
await podcast.save();
// Parse and create episodes
const episodes = rssParserService.parseEpisodes(feedData.episodes, feedData.imageUrl);
const episodes = rssParserService.parseEpisodes(feedData.episodes, feedData.imageUrl, rssUrl);
for (const episodeData of episodes) {
const episode = new Episode({
+41 -4
View File
@@ -65,6 +65,20 @@ export default function Library() {
},
});
const syncMutation = useMutation({
mutationFn: (podcastId: string) => podcastService.syncPodcast(podcastId),
onSuccess: (data, podcastId) => {
console.log(`[Sync SUCCESS] Podcast ID: ${podcastId}`, data);
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
queryClient.invalidateQueries({ queryKey: ['episodes', 'latest'] });
},
onError: (error: any, podcastId) => {
console.error(`[Sync FAILED] Podcast ID: ${podcastId}`, error);
const errorMessage = error.response?.data?.message || 'Erreur de synchronisation';
alert(`Erreur : ${errorMessage}`);
},
});
const handleLogout = async () => {
try {
await authService.logout();
@@ -233,10 +247,33 @@ export default function Library() {
) : (
<div className="mt-auto">
{podcast.episodeCount === 0 && (
<p className="text-[10px] text-orange-500 font-medium mb-3 flex items-center gap-1">
<Loader className="w-3 h-3 animate-spin" />
Episodes en cours de synchronisation...
</p>
<div className="mb-4">
<p className="text-[10px] text-orange-500 font-semibold mb-2 flex items-center gap-1 uppercase tracking-wider">
{syncMutation.isPending && syncMutation.variables === (podcast.subscriptionId || podcast._id) ? (
<Loader className="w-3 h-3 animate-spin" />
) : (
<span className="w-1.5 h-1.5 rounded-full bg-orange-500 animate-pulse" />
)}
Aucun épisode trouvé
</p>
<button
onClick={() => syncMutation.mutate(podcast.subscriptionId || podcast._id)}
disabled={syncMutation.isPending}
className="w-full py-2 rounded-xl bg-orange-50 text-orange-600 hover:bg-orange-100 transition-all text-xs font-bold flex items-center justify-center gap-2 border border-orange-200/50"
>
{syncMutation.isPending && syncMutation.variables === (podcast.subscriptionId || podcast._id) ? (
<>
<Loader className="w-3 h-3 animate-spin" />
Synchronisation...
</>
) : (
<>
<Loader className="w-3 h-3" />
Synchroniser maintenant
</>
)}
</button>
</div>
)}
<button
onClick={() => handleUnsubscribeRequest(podcast.subscriptionId || podcast._id)}
+5
View File
@@ -37,6 +37,11 @@ export const podcastService = {
return response.data;
},
syncPodcast: async (podcastId: string): Promise<any> => {
const response = await api.post(`/podcasts/${podcastId}/sync`);
return response.data;
},
searchPodcasts: async (
query: string,
limit: number = 20,