mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-23 06:58:03 +02:00
feat: implement podcast discovery with PodcastIndex API integration and Discover page
This commit is contained in:
1 parent
d7f158a498
commit
9bb61cc8a7
10 files changed
+1254
-5
No files matched your search
@@ -2,12 +2,20 @@ import { Request, Response } from 'express';
|
||||
import { Podcast } from '../models/Podcast';
|
||||
import { UserSubscription } from '../models/UserSubscription';
|
||||
import { Episode } from '../models/Episode';
|
||||
import { podcastIndexService } from '../services/podcastIndexService';
|
||||
import { rssParserService } from '../services/rssParserService';
|
||||
import { z } from 'zod';
|
||||
|
||||
const subscribeSchema = z.object({
|
||||
rssUrl: z.string().url('Invalid RSS URL'),
|
||||
});
|
||||
|
||||
const searchSchema = z.object({
|
||||
q: z.string().min(2, 'Query must be at least 2 characters'),
|
||||
limit: z.number().int().min(1).max(50).optional().default(20),
|
||||
offset: z.number().int().min(0).optional().default(0),
|
||||
});
|
||||
|
||||
export const getUserSubscriptions = async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
@@ -145,3 +153,134 @@ export const getPodcast = async (req: Request, res: Response) => {
|
||||
res.status(500).json({ message: 'Failed to fetch podcast' });
|
||||
}
|
||||
};
|
||||
|
||||
export const discoverPodcasts = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { q, limit = 20, offset = 0 } = searchSchema.parse(req.query);
|
||||
|
||||
// If PodcastIndex is not configured, return message
|
||||
if (!podcastIndexService.isConfigured()) {
|
||||
// Return empty for now, user can subscribe via RSS URL
|
||||
return res.json({
|
||||
source: 'local',
|
||||
podcasts: [],
|
||||
count: 0,
|
||||
message: 'PodcastIndex API not configured - use RSS URL to subscribe',
|
||||
});
|
||||
}
|
||||
|
||||
// Search via PodcastIndex
|
||||
const results = await podcastIndexService.searchPodcasts(
|
||||
q as string,
|
||||
limit as number,
|
||||
offset as number
|
||||
);
|
||||
|
||||
res.json({
|
||||
source: 'podcastindex',
|
||||
podcasts: results,
|
||||
count: results.length,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Discover podcasts error:', error.message);
|
||||
res.status(error.status || 500).json({
|
||||
message: error.message || 'Search failed',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const subscribeFromDiscovery = async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const { rssUrl, title, author, imageUrl } = req.body;
|
||||
|
||||
if (!rssUrl) {
|
||||
return res.status(400).json({ message: 'RSS URL required' });
|
||||
}
|
||||
|
||||
// Check if podcast already in database
|
||||
let podcast = await Podcast.findOne({ rssUrl });
|
||||
|
||||
// If not in database, try to fetch and create it
|
||||
if (!podcast) {
|
||||
try {
|
||||
const result = await rssParserService.createPodcastFromRss(rssUrl, {
|
||||
title: title || 'Unknown Podcast',
|
||||
description: '',
|
||||
author: author || '',
|
||||
imageUrl: imageUrl,
|
||||
episodes: [],
|
||||
});
|
||||
podcast = result.podcast;
|
||||
} catch (error: any) {
|
||||
// If podcast creation fails, create minimal entry
|
||||
if (error.message.includes('already in database')) {
|
||||
podcast = await Podcast.findOne({ rssUrl });
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
message: `Failed to fetch podcast: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!podcast) {
|
||||
return res.status(400).json({ message: 'Failed to add podcast' });
|
||||
}
|
||||
|
||||
// Check if already subscribed
|
||||
const existingSubscription = await UserSubscription.findOne({
|
||||
userId: req.user.id,
|
||||
podcastId: podcast._id,
|
||||
});
|
||||
|
||||
if (existingSubscription) {
|
||||
return res.status(409).json({ message: 'Already subscribed to this podcast' });
|
||||
}
|
||||
|
||||
// Create subscription
|
||||
const subscription = new UserSubscription({
|
||||
userId: req.user.id,
|
||||
podcastId: podcast._id,
|
||||
});
|
||||
|
||||
await subscription.save();
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Subscribed successfully',
|
||||
podcast: podcast.toJSON(),
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Subscribe from discovery error:', error);
|
||||
res.status(500).json({ message: 'Failed to subscribe' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getTrendingPodcasts = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { limit = 20 } = req.query;
|
||||
|
||||
if (!podcastIndexService.isConfigured()) {
|
||||
return res.json({
|
||||
source: 'local',
|
||||
podcasts: [],
|
||||
count: 0,
|
||||
message: 'PodcastIndex API not configured',
|
||||
});
|
||||
}
|
||||
|
||||
const trending = await podcastIndexService.getTrendingPodcasts(limit as number);
|
||||
|
||||
res.json({
|
||||
source: 'podcastindex',
|
||||
podcasts: trending,
|
||||
count: trending.length,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Trending podcasts error:', error.message);
|
||||
res.status(500).json({ message: 'Failed to fetch trending podcasts' });
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
unsubscribe,
|
||||
searchPodcasts,
|
||||
getPodcast,
|
||||
discoverPodcasts,
|
||||
subscribeFromDiscovery,
|
||||
getTrendingPodcasts,
|
||||
} from '../controllers/podcastController';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
|
||||
@@ -13,9 +16,12 @@ const router = Router();
|
||||
// Protected routes
|
||||
router.get('/subscriptions', authenticate, getUserSubscriptions);
|
||||
router.post('/subscribe', authenticate, subscribe);
|
||||
router.post('/subscribe-discovery', authenticate, subscribeFromDiscovery);
|
||||
router.delete('/:podcastId/unsubscribe', authenticate, unsubscribe);
|
||||
|
||||
// Public routes
|
||||
// Public/discovery routes
|
||||
router.get('/discover', discoverPodcasts);
|
||||
router.get('/trending', getTrendingPodcasts);
|
||||
router.get('/search', searchPodcasts);
|
||||
router.get('/:podcastId', getPodcast);
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import axios from 'axios';
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* PodcastIndex API Service
|
||||
* Integrates with Podcast Index API for podcast search and data
|
||||
* https://podcastindex-api.com/
|
||||
*/
|
||||
|
||||
interface PodcastIndexResponse {
|
||||
status: string;
|
||||
feeds: Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
description: string;
|
||||
author?: string;
|
||||
image?: string;
|
||||
artwork?: string;
|
||||
language?: string;
|
||||
categories?: Record<string, string>;
|
||||
lastUpdate?: number;
|
||||
episodeCount?: number;
|
||||
}>;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
rssUrl: string;
|
||||
description: string;
|
||||
author?: string;
|
||||
imageUrl?: string;
|
||||
language?: string;
|
||||
episodeCount?: number;
|
||||
}
|
||||
|
||||
const API_KEY = process.env.PODCAST_INDEX_API_KEY;
|
||||
const API_SECRET = process.env.PODCAST_INDEX_API_SECRET;
|
||||
const API_BASE = 'https://api.podcastindex.org/api/1.0';
|
||||
|
||||
// Create auth headers for PodcastIndex API
|
||||
const getAuthHeaders = () => {
|
||||
if (!API_KEY || !API_SECRET) {
|
||||
throw new Error('PodcastIndex API credentials not configured');
|
||||
}
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const auth = `${API_KEY}${API_SECRET}${timestamp}`;
|
||||
const authHash = crypto.createHash('sha1').update(auth).digest('hex');
|
||||
|
||||
return {
|
||||
'X-Auth-Date': timestamp.toString(),
|
||||
'X-Auth-Digest': authHash,
|
||||
'User-Agent': 'Podcastic (https://podcastic.app)',
|
||||
};
|
||||
};
|
||||
|
||||
export const podcastIndexService = {
|
||||
/**
|
||||
* Search for podcasts by query
|
||||
*/
|
||||
searchPodcasts: async (
|
||||
query: string,
|
||||
limit: number = 20,
|
||||
offset: number = 0
|
||||
): Promise<SearchResult[]> => {
|
||||
try {
|
||||
if (!query || query.trim().length < 2) {
|
||||
throw new Error('Search query too short (minimum 2 characters)');
|
||||
}
|
||||
|
||||
const response = await axios.get<PodcastIndexResponse>(
|
||||
`${API_BASE}/podcasts/search`,
|
||||
{
|
||||
params: {
|
||||
q: query,
|
||||
limit: Math.min(limit, 50), // Cap at 50
|
||||
offset,
|
||||
},
|
||||
headers: getAuthHeaders(),
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data.feeds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.data.feeds.map((feed) => ({
|
||||
id: feed.id.toString(),
|
||||
title: feed.title,
|
||||
rssUrl: feed.url,
|
||||
description: feed.description || '',
|
||||
author: feed.author || '',
|
||||
imageUrl: feed.artwork || feed.image,
|
||||
language: feed.language || 'en',
|
||||
episodeCount: feed.episodeCount,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
console.error('PodcastIndex search error:', error.message);
|
||||
|
||||
// Return empty array instead of throwing to graceful degrade
|
||||
if (error.response?.status === 429) {
|
||||
throw new Error('Search rate limited. Please try again later.');
|
||||
}
|
||||
if (error.response?.status === 401) {
|
||||
throw new Error('PodcastIndex API authentication failed');
|
||||
}
|
||||
|
||||
throw new Error(`Search failed: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get podcast by RSS URL
|
||||
*/
|
||||
getPodcastByRssUrl: async (rssUrl: string): Promise<SearchResult | null> => {
|
||||
try {
|
||||
const response = await axios.get<PodcastIndexResponse>(
|
||||
`${API_BASE}/podcasts/byfeedurl`,
|
||||
{
|
||||
params: { url: rssUrl },
|
||||
headers: getAuthHeaders(),
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data.feeds || response.data.feeds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const feed = response.data.feeds[0];
|
||||
return {
|
||||
id: feed.id.toString(),
|
||||
title: feed.title,
|
||||
rssUrl: feed.url,
|
||||
description: feed.description || '',
|
||||
author: feed.author || '',
|
||||
imageUrl: feed.artwork || feed.image,
|
||||
language: feed.language || 'en',
|
||||
episodeCount: feed.episodeCount,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('PodcastIndex lookup error:', error.message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Search by podcast title
|
||||
*/
|
||||
searchByTitle: async (title: string, limit: number = 10): Promise<SearchResult[]> => {
|
||||
try {
|
||||
const response = await axios.get<PodcastIndexResponse>(
|
||||
`${API_BASE}/podcasts/search`,
|
||||
{
|
||||
params: {
|
||||
q: title,
|
||||
limit: Math.min(limit, 30),
|
||||
},
|
||||
headers: getAuthHeaders(),
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data.feeds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.data.feeds.map((feed) => ({
|
||||
id: feed.id.toString(),
|
||||
title: feed.title,
|
||||
rssUrl: feed.url,
|
||||
description: feed.description || '',
|
||||
author: feed.author || '',
|
||||
imageUrl: feed.artwork || feed.image,
|
||||
language: feed.language || 'en',
|
||||
episodeCount: feed.episodeCount,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
console.error('Title search error:', error.message);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get trending podcasts
|
||||
*/
|
||||
getTrendingPodcasts: async (limit: number = 20): Promise<SearchResult[]> => {
|
||||
try {
|
||||
const response = await axios.get<PodcastIndexResponse>(
|
||||
`${API_BASE}/podcasts/trending`,
|
||||
{
|
||||
params: { limit: Math.min(limit, 50) },
|
||||
headers: getAuthHeaders(),
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data.feeds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.data.feeds.map((feed) => ({
|
||||
id: feed.id.toString(),
|
||||
title: feed.title,
|
||||
rssUrl: feed.url,
|
||||
description: feed.description || '',
|
||||
author: feed.author || '',
|
||||
imageUrl: feed.artwork || feed.image,
|
||||
language: feed.language || 'en',
|
||||
episodeCount: feed.episodeCount,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
console.error('Trending podcasts error:', error.message);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if API credentials are configured
|
||||
*/
|
||||
isConfigured: (): boolean => {
|
||||
return !!(API_KEY && API_SECRET);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
import Parser from 'rss-parser';
|
||||
import { Podcast } from '../models/Podcast';
|
||||
import { Episode } from '../models/Episode';
|
||||
import { getRedis } from '../config/redis';
|
||||
|
||||
const parser = new Parser({
|
||||
timeout: 20000, // 20 second timeout
|
||||
customFields: {
|
||||
item: [
|
||||
['media:content', 'mediaContent'],
|
||||
['enclosure', 'enclosure'],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
interface ParsedEpisode {
|
||||
title: string;
|
||||
description: string;
|
||||
audioUrl: string;
|
||||
pubDate: Date;
|
||||
duration: number;
|
||||
guid: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RSS feed and extract podcast & episode data
|
||||
*/
|
||||
export const rssParserService = {
|
||||
/**
|
||||
* Parse RSS feed from URL
|
||||
*/
|
||||
parseFeed: async (rssUrl: string) => {
|
||||
try {
|
||||
const feed = await parser.parseURL(rssUrl);
|
||||
|
||||
if (!feed.title) {
|
||||
throw new Error('Invalid RSS feed - missing title');
|
||||
}
|
||||
|
||||
return {
|
||||
title: feed.title,
|
||||
description: feed.description || '',
|
||||
author: feed.author || feed.creator || '',
|
||||
imageUrl: feed.image?.url || feed.itunes?.image,
|
||||
language: feed.language || 'en',
|
||||
episodes: feed.items || [],
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('RSS parsing error:', error.message);
|
||||
throw new Error(`Failed to parse RSS feed: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Extract audio URL from episode item
|
||||
*/
|
||||
extractAudioUrl: (item: any): string | null => {
|
||||
// Try enclosure first (standard RSS)
|
||||
if (item.enclosure?.url) {
|
||||
return item.enclosure.url;
|
||||
}
|
||||
|
||||
// Try media:content
|
||||
if (item.mediaContent?.url) {
|
||||
return item.mediaContent.url;
|
||||
}
|
||||
|
||||
// Try iTunes:duration for audio detection
|
||||
if (item['content:encoded']) {
|
||||
const match = item['content:encoded'].match(/href=["']([^"']*\.mp3)["']/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Extract episode duration in seconds
|
||||
*/
|
||||
extractDuration: (item: any): number => {
|
||||
// Try iTunes duration
|
||||
if (item.itunes?.duration) {
|
||||
const duration = item.itunes.duration;
|
||||
// Handle "HH:MM:SS" or "MM:SS" or seconds format
|
||||
if (typeof duration === 'string') {
|
||||
const parts = duration.split(':').map(Number);
|
||||
if (parts.length === 3) {
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
} else if (parts.length === 2) {
|
||||
return parts[0] * 60 + parts[1];
|
||||
} else if (parts.length === 1) {
|
||||
return parts[0];
|
||||
}
|
||||
}
|
||||
if (typeof duration === 'number') {
|
||||
return duration;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Extract episode image URL
|
||||
*/
|
||||
extractImageUrl: (item: any, podcastImage?: string): string | undefined => {
|
||||
// Try iTunes image first
|
||||
if (item.itunes?.image) {
|
||||
return item.itunes.image;
|
||||
}
|
||||
|
||||
// Try media content
|
||||
if (item.mediaContent?.medium === 'image') {
|
||||
return item.mediaContent.url;
|
||||
}
|
||||
|
||||
// Fall back to podcast image
|
||||
return podcastImage;
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse episodes from RSS feed items
|
||||
*/
|
||||
parseEpisodes: (items: any[], podcastImage?: string): ParsedEpisode[] => {
|
||||
return items
|
||||
.filter((item) => {
|
||||
const audioUrl = rssParserService.extractAudioUrl(item);
|
||||
return !!audioUrl && !!item.title; // Must have both audio and title
|
||||
})
|
||||
.map((item) => ({
|
||||
title: item.title || 'Untitled Episode',
|
||||
description: item.contentSnippet || item.description || '',
|
||||
audioUrl: rssParserService.extractAudioUrl(item) || '',
|
||||
pubDate: new Date(item.pubDate || Date.now()),
|
||||
duration: rssParserService.extractDuration(item),
|
||||
guid: item.guid || item.link || `${item.title}-${item.pubDate}`,
|
||||
imageUrl: rssParserService.extractImageUrl(item, podcastImage),
|
||||
}))
|
||||
.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime()); // Sort newest first
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch and sync podcast episodes
|
||||
*/
|
||||
syncPodcastEpisodes: async (podcastId: string, rssUrl: string) => {
|
||||
try {
|
||||
const redis = getRedis();
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `podcast:${podcastId}:sync`;
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
const podcast = await Podcast.findById(podcastId);
|
||||
if (!podcast) {
|
||||
throw new Error('Podcast not found');
|
||||
}
|
||||
|
||||
// Parse RSS feed
|
||||
const feedData = await rssParserService.parseFeed(rssUrl);
|
||||
const episodes = rssParserService.parseEpisodes(feedData.episodes, feedData.imageUrl);
|
||||
|
||||
// Upsert episodes
|
||||
const createdCount = await Promise.all(
|
||||
episodes.map(async (episodeData) => {
|
||||
const existing = await Episode.findOne({
|
||||
podcastId,
|
||||
guid: episodeData.guid,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
const episode = new Episode({
|
||||
podcastId,
|
||||
title: episodeData.title,
|
||||
description: episodeData.description,
|
||||
audioUrl: episodeData.audioUrl,
|
||||
pubDate: episodeData.pubDate,
|
||||
duration: episodeData.duration,
|
||||
guid: episodeData.guid,
|
||||
imageUrl: episodeData.imageUrl,
|
||||
});
|
||||
|
||||
await episode.save();
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
);
|
||||
|
||||
const newCount = createdCount.filter((c) => c === 1).length;
|
||||
|
||||
// Update podcast metadata
|
||||
podcast.lastFetched = new Date();
|
||||
podcast.episodeCount = await Episode.countDocuments({ podcastId });
|
||||
await podcast.save();
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
newEpisodes: newCount,
|
||||
totalEpisodes: podcast.episodeCount,
|
||||
lastFetched: podcast.lastFetched,
|
||||
};
|
||||
|
||||
// Cache for 6 hours
|
||||
await redis.setEx(cacheKey, 6 * 60 * 60, JSON.stringify(result));
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('Podcast sync error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create new podcast from RSS feed
|
||||
*/
|
||||
createPodcastFromRss: async (
|
||||
rssUrl: string,
|
||||
feedData?: any
|
||||
): Promise<{
|
||||
podcast: any;
|
||||
episodes: number;
|
||||
}> => {
|
||||
try {
|
||||
// Check if podcast already exists
|
||||
let podcast = await Podcast.findOne({ rssUrl });
|
||||
if (podcast) {
|
||||
throw new Error('Podcast already in database');
|
||||
}
|
||||
|
||||
// Parse feed if not provided
|
||||
if (!feedData) {
|
||||
feedData = await rssParserService.parseFeed(rssUrl);
|
||||
}
|
||||
|
||||
// Create podcast
|
||||
podcast = new Podcast({
|
||||
title: feedData.title,
|
||||
description: feedData.description,
|
||||
rssUrl,
|
||||
imageUrl: feedData.imageUrl,
|
||||
author: feedData.author,
|
||||
language: feedData.language || 'en',
|
||||
category: [],
|
||||
episodeCount: 0,
|
||||
});
|
||||
|
||||
await podcast.save();
|
||||
|
||||
// Parse and create episodes
|
||||
const episodes = rssParserService.parseEpisodes(feedData.episodes, feedData.imageUrl);
|
||||
|
||||
for (const episodeData of episodes) {
|
||||
const episode = new Episode({
|
||||
podcastId: podcast._id,
|
||||
title: episodeData.title,
|
||||
description: episodeData.description,
|
||||
audioUrl: episodeData.audioUrl,
|
||||
pubDate: episodeData.pubDate,
|
||||
duration: episodeData.duration,
|
||||
guid: episodeData.guid,
|
||||
imageUrl: episodeData.imageUrl,
|
||||
});
|
||||
|
||||
try {
|
||||
await episode.save();
|
||||
} catch (error) {
|
||||
// Skip duplicate episodes
|
||||
console.log(`Skipping duplicate episode: ${episodeData.guid}`);
|
||||
}
|
||||
}
|
||||
|
||||
podcast.episodeCount = await Episode.countDocuments({ podcastId: podcast._id });
|
||||
podcast.lastFetched = new Date();
|
||||
await podcast.save();
|
||||
|
||||
return {
|
||||
podcast: podcast.toJSON(),
|
||||
episodes: podcast.episodeCount,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Create podcast error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user