feat: implement podcast discovery with PodcastIndex API integration and Discover page

This commit is contained in:
Michael committed 2026-04-16 10:01:05 +02:00
1 parent d7f158a498
commit 9bb61cc8a7
10 files changed
+1254 -5

No files matched your search

+5 -3
View File
@@ -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
+227
View File
@@ -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)
@@ -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' });
}
};
+7 -1
View File
@@ -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);
+228
View File
@@ -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);
},
};
+289
View File
@@ -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;
}
},
};
+16 -1
View File
@@ -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 <Navigate to="/login" replace />;
}
return <>{children}</>;
return (
<div className="flex">
<Navigation />
<div className="flex-1 lg:ml-64">{children}</div>
</div>
);
}
export default function App() {
@@ -39,6 +46,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/discover"
element={
<ProtectedRoute>
<Discover />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
+54
View File
@@ -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 (
<nav className="fixed left-0 top-0 h-screen w-64 bg-white border-r border-light-200 pt-6 overflow-y-auto hidden lg:flex flex-col">
{/* Logo */}
<Link to="/" className="px-6 mb-8 flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-primary flex items-center justify-center text-white font-bold">
🎙️
</div>
<span className="text-xl font-bold text-light-900">Podcastic</span>
</Link>
{/* Navigation Links */}
<div className="flex-1 px-4">
<div className="space-y-2">
<Link
to="/dashboard"
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
isActive('/dashboard')
? 'bg-blue-500 text-white'
: 'text-light-700 hover:bg-light-100'
}`}
>
<Home className="w-5 h-5" />
<span className="font-medium">Dashboard</span>
</Link>
<Link
to="/discover"
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
isActive('/discover')
? 'bg-blue-500 text-white'
: 'text-light-700 hover:bg-light-100'
}`}
>
<Compass className="w-5 h-5" />
<span className="font-medium">Discover</span>
</Link>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-light-200 text-xs text-light-500">
<p>Podcastic © 2026</p>
</div>
</nav>
);
}
+235
View File
@@ -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<any>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedTab, setSelectedTab] = useState<'search' | 'trending'>('trending');
const [subscribingId, setSubscribingId] = useState<string | null>(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 (
<div className="min-h-screen bg-light-50">
<Header
title="Discover"
subtitle="Find and subscribe to podcasts"
user={user}
onLogout={handleLogout}
/>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Search Bar */}
<div className="mb-12">
<div className="relative">
<Search className="absolute left-4 top-3.5 w-5 h-5 text-light-400" />
<input
type="text"
placeholder="Search podcasts, authors, topics..."
value={searchQuery}
onChange={(e) => 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"
/>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 mb-8">
<button
onClick={() => setSelectedTab('trending')}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
selectedTab === 'trending'
? 'bg-blue-500 text-white'
: 'bg-light-200 text-light-700 hover:bg-light-300'
}`}
>
🔥 Trending
</button>
<button
onClick={() => setSelectedTab('search')}
disabled={searchQuery.length < 2}
className={`px-6 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
selectedTab === 'search'
? 'bg-blue-500 text-white'
: 'bg-light-200 text-light-700 hover:bg-light-300'
}`}
>
🔍 Search Results
</button>
</div>
{/* Results Grid */}
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<Loader className="w-12 h-12 text-blue-500 animate-spin mx-auto mb-3" />
<p className="text-light-600">Loading podcasts...</p>
</div>
</div>
) : displayData && displayData.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{displayData.map((podcast) => (
<div key={podcast.id} className="card hover:shadow-lg transition-shadow">
{/* Podcast Image */}
{podcast.imageUrl && (
<div className="mb-4 rounded-lg overflow-hidden aspect-video bg-light-200">
<img
src={podcast.imageUrl}
alt={podcast.title}
className="w-full h-full object-cover"
/>
</div>
)}
{/* Content */}
<div className="flex-1">
<h3 className="text-lg font-bold text-light-900 mb-1 line-clamp-2">
{podcast.title}
</h3>
{podcast.author && (
<p className="text-sm text-light-600 mb-2">{podcast.author}</p>
)}
<p className="text-sm text-light-500 line-clamp-3 mb-4">
{podcast.description}
</p>
<div className="flex items-center justify-between">
<div className="flex gap-2 text-xs text-light-500">
{podcast.episodeCount && (
<span>{podcast.episodeCount} episodes</span>
)}
{podcast.language && <span>{podcast.language}</span>}
</div>
<button
onClick={() => handleSubscribe(podcast)}
disabled={subscribingId === podcast.id || subscribeMutation.isPending}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{subscribingId === podcast.id ? (
<>
<Loader className="w-4 h-4 animate-spin" />
Adding...
</>
) : (
<>
<Plus className="w-4 h-4" />
Add
</>
)}
</button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-20">
<p className="text-light-600 text-lg mb-2">
{selectedTab === 'search' && searchQuery.length < 2
? 'Type at least 2 characters to search'
: 'No podcasts found'}
</p>
{selectedTab === 'search' && (
<p className="text-light-500">
Try searching for podcast titles, authors, or topics
</p>
)}
</div>
)}
{/* Source Info */}
{(searchResults || trendingData) && (
<div className="mt-8 text-center text-sm text-light-500">
<p>
Powered by{' '}
<a
href="https://podcastindex.org"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
Podcast Index
</a>
</p>
</div>
)}
</main>
</div>
);
}
+54
View File
@@ -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<DiscoveryResponse> => {
const response = await api.get('/podcasts/discover', {
params: { q: query, limit, offset },
});
return response.data;
},
getTrendingPodcasts: async (limit: number = 20): Promise<DiscoveryResponse> => {
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;
},
};