diff --git a/.env.example b/.env.example index 0d2df15..b630ce3 100644 --- a/.env.example +++ b/.env.example @@ -16,9 +16,11 @@ JWT_SECRET=your_super_secret_jwt_key_change_in_production JWT_EXPIRES_IN=7d JWT_REFRESH_EXPIRES_IN=30d -# Podcast API (PodcastIndex) -PODCAST_INDEX_API_KEY=your_podcast_index_api_key -PODCAST_INDEX_API_SECRET=your_podcast_index_secret +# Podcast API (PodcastIndex) - OPTIONAL but recommended for search feature +# Get free API keys at: https://podcastindex-api.com/ +# These enable podcast discovery and search functionality +PODCAST_INDEX_API_KEY= +PODCAST_INDEX_API_SECRET= # Frontend Configuration VITE_API_URL=http://localhost:5000/api diff --git a/PODCAST_INDEX_SETUP.md b/PODCAST_INDEX_SETUP.md new file mode 100644 index 0000000..f9f85ce --- /dev/null +++ b/PODCAST_INDEX_SETUP.md @@ -0,0 +1,227 @@ +# 🔍 Podcast Index Integration Setup + +This guide explains how to set up Podcast Index API for podcast discovery and search functionality in Podcastic. + +## What is Podcast Index? + +[Podcast Index](https://podcastindex.org/) is a free, independent podcast database and API that provides: +- Podcast search functionality +- Trending podcasts +- Podcast metadata (title, author, image, description) +- Episode information +- No ads, no corporate tracking + +## Prerequisites + +- Podcastic backend running +- Internet connection +- Free Podcast Index account + +## Step 1: Get API Keys + +### Register on Podcast Index +1. Visit [Podcast Index API](https://podcastindex-api.com/) +2. Click "Sign Up" (top right) +3. Create a free account with your email +4. Check your email for verification link +5. Confirm your email address + +### Generate API Keys +1. Log in to your Podcast Index account +2. Go to "My API Keys" section +3. You'll see: + - **API Key** (e.g., `abcdef123456`) + - **API Secret** (e.g., `xyz789`) +4. Copy both values + +## Step 2: Configure Environment Variables + +### Update `.env` file +```bash +# In the root Podcastic directory +nano .env # or edit with your preferred editor +``` + +Add these lines: +```env +PODCAST_INDEX_API_KEY=your_api_key_here +PODCAST_INDEX_API_SECRET=your_api_secret_here +``` + +**Example:** +```env +PODCAST_INDEX_API_KEY=abcdef123456 +PODCAST_INDEX_API_SECRET=xyz789opqrst +``` + +### Docker Setup +If using Docker, the environment variables will be automatically loaded from `.env`: + +```bash +docker-compose up -d +``` + +The backend will automatically detect and use your API keys. + +## Step 3: Verify Setup + +### Test API Integration +```bash +# Start the backend (if not running) +cd backend +npm run dev + +# In another terminal, test the API +curl http://localhost:5000/api/podcasts/discover?q=technology +``` + +Expected response: +```json +{ + "source": "podcastindex", + "podcasts": [ + { + "id": "25566", + "title": "Example Podcast", + "rssUrl": "https://example.com/feed", + "author": "Podcast Author", + "description": "...", + "imageUrl": "...", + "episodeCount": 150 + } + ], + "count": 10 +} +``` + +### Test in Frontend +1. Open http://localhost:3000/discover +2. Click on the "🔍 Search Results" tab +3. Type a podcast name (e.g., "technology", "news", "music") +4. Results should appear from Podcast Index + +## Features Enabled by API + +### ✅ Podcast Discovery +- Search by keyword +- Browse trending podcasts +- View podcast metadata + +### ✅ Quick Subscribe +- Find podcasts directly from discover page +- One-click subscribe +- Auto-fetch episodes + +### Example Searches +- **Popular podcasts**: "Joe Rogan", "NPR", "BBC" +- **Topics**: "technology", "news", "business", "science" +- **Specific shows**: "The Daily", "Serial", "Freakonomics" + +## API Limits + +### Free Tier (Default) +- **100 requests per minute** per API key +- Sufficient for small to medium users +- No additional signup needed + +### If You Need More +- Podcast Index offers higher rate limits for supporters +- See their pricing page for details + +## Fallback When API is Unavailable + +If PodcastIndex API is not configured or unavailable: +- Users can still subscribe manually using RSS URLs +- Dashboard shows previously subscribed podcasts +- Graceful degradation - app still works + +## Troubleshooting + +### "API credentials not configured" +**Solution**: Check that both `PODCAST_INDEX_API_KEY` and `PODCAST_INDEX_API_SECRET` are set in `.env` + +```bash +# Verify environment variables +echo $PODCAST_INDEX_API_KEY +echo $PODCAST_INDEX_API_SECRET +``` + +### "Search rate limited" +**Error**: `Search rate limited. Please try again later.` + +**Solution**: +- Wait a minute before searching again +- Rate limit resets every 60 seconds +- This is normal with free tier - switch searches if needed + +### "Authentication failed" +**Error**: `PodcastIndex API authentication failed` + +**Solution**: +- Verify your API keys are correct +- Check for extra spaces/newlines in `.env` +- Regenerate API keys on Podcast Index website if needed + +### No results appear +**Possible causes**: +- API key not configured +- Internet connection issue +- Podcast doesn't exist in Podcast Index database + +**Solution**: +- Try a very common podcast name first (e.g., "NPR") +- Check your internet connection +- Fall back to subscribing via RSS URL if needed + +## Security Notes + +⚠️ **Important** +- Never commit `.env` to git (it's in `.gitignore`) +- Don't share your API secret publicly +- API keys are for your use only +- Regenerate keys if accidentally exposed + +## Using RSS URLs as Alternative + +If you don't want to use Podcast Index: + +1. Find podcast RSS URL: + - On the podcast's website + - Search "[Podcast Name] RSS" in Google + - Common format: `example.com/feed` or `example.com/podcast/rss` + +2. Subscribe directly: + - Go to Dashboard + - Click Subscribe (or future manual add feature) + - Paste RSS URL + - Podcastic fetches episodes automatically + +## API Documentation + +Full API reference: [Podcast Index Documentation](https://podcastindex-api.com/docs/index.html) + +Key endpoints available in Podcastic: +- `GET /api/podcasts/discover` - Search podcasts +- `GET /api/podcasts/trending` - Get trending podcasts +- `POST /api/podcasts/subscribe-discovery` - Subscribe from search results + +## Advanced: Custom Podcast Sources + +To use a different podcast database: +1. Implement a new service in `backend/src/services/` +2. Add environment variables for API credentials +3. Update `podcastController.ts` to use the new service +4. No frontend changes needed! + +Example: Could integrate Apple Podcasts API, Spotify API, or self-hosted podcast database. + +## Support + +- **Podcast Index Support**: https://podcastindex.org/contact +- **Podcastic Issues**: Create an issue on GitHub +- **RSS Feed Help**: See `GETTING_STARTED.md` for manual subscription + +--- + +**Last Updated**: 2026-04-16 +**Podcast Index Status**: [https://podcastindex.org/status](https://podcastindex.org/status) diff --git a/backend/src/controllers/podcastController.ts b/backend/src/controllers/podcastController.ts index 9897258..e0fb4e4 100644 --- a/backend/src/controllers/podcastController.ts +++ b/backend/src/controllers/podcastController.ts @@ -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' }); + } +}; diff --git a/backend/src/routes/podcastRoutes.ts b/backend/src/routes/podcastRoutes.ts index 3f641bb..9b09be4 100644 --- a/backend/src/routes/podcastRoutes.ts +++ b/backend/src/routes/podcastRoutes.ts @@ -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); diff --git a/backend/src/services/podcastIndexService.ts b/backend/src/services/podcastIndexService.ts new file mode 100644 index 0000000..979800c --- /dev/null +++ b/backend/src/services/podcastIndexService.ts @@ -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; + 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 => { + try { + if (!query || query.trim().length < 2) { + throw new Error('Search query too short (minimum 2 characters)'); + } + + const response = await axios.get( + `${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 => { + try { + const response = await axios.get( + `${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 => { + try { + const response = await axios.get( + `${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 => { + try { + const response = await axios.get( + `${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); + }, +}; diff --git a/backend/src/services/rssParserService.ts b/backend/src/services/rssParserService.ts new file mode 100644 index 0000000..36770c3 --- /dev/null +++ b/backend/src/services/rssParserService.ts @@ -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; + } + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c0b66c5..2f04d15 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,8 @@ import './styles/globals.css'; import { authService } from './services/authService'; import Login from './pages/Login'; import Dashboard from './pages/Dashboard'; +import Discover from './pages/Discover'; +import Navigation from './components/Navigation'; const queryClient = new QueryClient({ defaultOptions: { @@ -22,7 +24,12 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { return ; } - return <>{children}; + return ( +
+ +
{children}
+
+ ); } export default function App() { @@ -39,6 +46,14 @@ export default function App() { } /> + + + + } + /> } /> } /> diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx new file mode 100644 index 0000000..676dd44 --- /dev/null +++ b/frontend/src/components/Navigation.tsx @@ -0,0 +1,54 @@ +import { Link, useLocation } from 'react-router-dom'; +import { Home, Compass } from 'lucide-react'; + +export default function Navigation() { + const location = useLocation(); + + const isActive = (path: string) => location.pathname === path; + + return ( + + ); +} diff --git a/frontend/src/pages/Discover.tsx b/frontend/src/pages/Discover.tsx new file mode 100644 index 0000000..b5f8a1c --- /dev/null +++ b/frontend/src/pages/Discover.tsx @@ -0,0 +1,235 @@ +import { useState, useCallback } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { discoveryService, DiscoveryPodcast } from '../services/discoveryService'; +import { podcastService } from '../services/podcastService'; +import Header from '../components/Header'; +import { Search, Plus, Loader } from 'lucide-react'; +import { authService } from '../services/authService'; + +export default function Discover() { + const queryClient = useQueryClient(); + const [user, setUser] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedTab, setSelectedTab] = useState<'search' | 'trending'>('trending'); + const [subscribingId, setSubscribingId] = useState(null); + + // Load user + useState(() => { + const loadUser = async () => { + try { + const response = await authService.getMe(); + setUser(response.user); + } catch (error) { + console.error('Failed to load user:', error); + } + }; + loadUser(); + }, []); + + // Search podcasts + const { data: searchResults, isLoading: isSearching } = useQuery({ + queryKey: ['discover', 'search', searchQuery], + queryFn: () => discoveryService.discoverPodcasts(searchQuery, 30), + enabled: selectedTab === 'search' && searchQuery.length > 2, + staleTime: 5 * 60 * 1000, + }); + + // Trending podcasts + const { data: trendingData, isLoading: isTrendingLoading } = useQuery({ + queryKey: ['discover', 'trending'], + queryFn: () => discoveryService.getTrendingPodcasts(30), + enabled: selectedTab === 'trending', + staleTime: 30 * 60 * 1000, // Cache for 30 minutes + }); + + // Subscribe mutation + const subscribeMutation = useMutation({ + mutationFn: (podcast: DiscoveryPodcast) => + discoveryService.subscribeFromDiscovery(podcast.rssUrl, { + title: podcast.title, + author: podcast.author, + imageUrl: podcast.imageUrl, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] }); + }, + }); + + const handleSubscribe = async (podcast: DiscoveryPodcast) => { + try { + setSubscribingId(podcast.id); + await subscribeMutation.mutateAsync(podcast); + // Show success toast (you might want to add a toast library) + alert(`Added "${podcast.title}" to your subscriptions!`); + } catch (error: any) { + const errorMessage = error.response?.data?.message || 'Failed to subscribe'; + alert(`Error: ${errorMessage}`); + } finally { + setSubscribingId(null); + } + }; + + const handleLogout = async () => { + try { + await authService.logout(); + authService.clearTokens(); + window.location.href = '/login'; + } catch (error) { + console.error('Logout failed:', error); + } + }; + + const displayData = selectedTab === 'search' ? searchResults?.podcasts : trendingData?.podcasts; + const isLoading = selectedTab === 'search' ? isSearching : isTrendingLoading; + + return ( +
+
+ +
+ {/* Search Bar */} +
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-12 pr-4 py-3 rounded-xl border border-light-200 bg-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors text-lg" + /> +
+
+ + {/* Tabs */} +
+ + +
+ + {/* Results Grid */} + {isLoading ? ( +
+
+ +

Loading podcasts...

+
+
+ ) : displayData && displayData.length > 0 ? ( +
+ {displayData.map((podcast) => ( +
+ {/* Podcast Image */} + {podcast.imageUrl && ( +
+ {podcast.title} +
+ )} + + {/* Content */} +
+

+ {podcast.title} +

+ + {podcast.author && ( +

{podcast.author}

+ )} + +

+ {podcast.description} +

+ +
+
+ {podcast.episodeCount && ( + {podcast.episodeCount} episodes + )} + {podcast.language && {podcast.language}} +
+ + +
+
+
+ ))} +
+ ) : ( +
+

+ {selectedTab === 'search' && searchQuery.length < 2 + ? 'Type at least 2 characters to search' + : 'No podcasts found'} +

+ {selectedTab === 'search' && ( +

+ Try searching for podcast titles, authors, or topics +

+ )} +
+ )} + + {/* Source Info */} + {(searchResults || trendingData) && ( +
+

+ Powered by{' '} + + Podcast Index + +

+
+ )} +
+
+ ); +} diff --git a/frontend/src/services/discoveryService.ts b/frontend/src/services/discoveryService.ts new file mode 100644 index 0000000..596c833 --- /dev/null +++ b/frontend/src/services/discoveryService.ts @@ -0,0 +1,54 @@ +import api from './api'; + +export interface DiscoveryPodcast { + id: string; + title: string; + rssUrl: string; + description: string; + author?: string; + imageUrl?: string; + language?: string; + episodeCount?: number; +} + +export interface DiscoveryResponse { + source: 'podcastindex' | 'local'; + podcasts: DiscoveryPodcast[]; + count: number; + message?: string; +} + +export const discoveryService = { + discoverPodcasts: async ( + query: string, + limit: number = 20, + offset: number = 0 + ): Promise => { + const response = await api.get('/podcasts/discover', { + params: { q: query, limit, offset }, + }); + return response.data; + }, + + getTrendingPodcasts: async (limit: number = 20): Promise => { + const response = await api.get('/podcasts/trending', { + params: { limit }, + }); + return response.data; + }, + + subscribeFromDiscovery: async ( + rssUrl: string, + metadata?: { + title?: string; + author?: string; + imageUrl?: string; + } + ): Promise<{ message: string; podcast: any }> => { + const response = await api.post('/podcasts/subscribe-discovery', { + rssUrl, + ...metadata, + }); + return response.data; + }, +};