mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-11 17:26:20 +02:00
feat: implement RSS parsing service and podcast controller for feed ingestion and episode synchronization
This commit is contained in:
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user