mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-11 17:26:20 +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
+5
-3
@@ -16,9 +16,11 @@ JWT_SECRET=your_super_secret_jwt_key_change_in_production
|
|||||||
JWT_EXPIRES_IN=7d
|
JWT_EXPIRES_IN=7d
|
||||||
JWT_REFRESH_EXPIRES_IN=30d
|
JWT_REFRESH_EXPIRES_IN=30d
|
||||||
|
|
||||||
# Podcast API (PodcastIndex)
|
# Podcast API (PodcastIndex) - OPTIONAL but recommended for search feature
|
||||||
PODCAST_INDEX_API_KEY=your_podcast_index_api_key
|
# Get free API keys at: https://podcastindex-api.com/
|
||||||
PODCAST_INDEX_API_SECRET=your_podcast_index_secret
|
# These enable podcast discovery and search functionality
|
||||||
|
PODCAST_INDEX_API_KEY=
|
||||||
|
PODCAST_INDEX_API_SECRET=
|
||||||
|
|
||||||
# Frontend Configuration
|
# Frontend Configuration
|
||||||
VITE_API_URL=http://localhost:5000/api
|
VITE_API_URL=http://localhost:5000/api
|
||||||
@@ -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 { Podcast } from '../models/Podcast';
|
||||||
import { UserSubscription } from '../models/UserSubscription';
|
import { UserSubscription } from '../models/UserSubscription';
|
||||||
import { Episode } from '../models/Episode';
|
import { Episode } from '../models/Episode';
|
||||||
|
import { podcastIndexService } from '../services/podcastIndexService';
|
||||||
|
import { rssParserService } from '../services/rssParserService';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const subscribeSchema = z.object({
|
const subscribeSchema = z.object({
|
||||||
rssUrl: z.string().url('Invalid RSS URL'),
|
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) => {
|
export const getUserSubscriptions = async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
@@ -145,3 +153,134 @@ export const getPodcast = async (req: Request, res: Response) => {
|
|||||||
res.status(500).json({ message: 'Failed to fetch podcast' });
|
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,
|
unsubscribe,
|
||||||
searchPodcasts,
|
searchPodcasts,
|
||||||
getPodcast,
|
getPodcast,
|
||||||
|
discoverPodcasts,
|
||||||
|
subscribeFromDiscovery,
|
||||||
|
getTrendingPodcasts,
|
||||||
} from '../controllers/podcastController';
|
} from '../controllers/podcastController';
|
||||||
import { authenticate } from '../middleware/auth';
|
import { authenticate } from '../middleware/auth';
|
||||||
|
|
||||||
@@ -13,9 +16,12 @@ const router = Router();
|
|||||||
// Protected routes
|
// Protected routes
|
||||||
router.get('/subscriptions', authenticate, getUserSubscriptions);
|
router.get('/subscriptions', authenticate, getUserSubscriptions);
|
||||||
router.post('/subscribe', authenticate, subscribe);
|
router.post('/subscribe', authenticate, subscribe);
|
||||||
|
router.post('/subscribe-discovery', authenticate, subscribeFromDiscovery);
|
||||||
router.delete('/:podcastId/unsubscribe', authenticate, unsubscribe);
|
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('/search', searchPodcasts);
|
||||||
router.get('/:podcastId', getPodcast);
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
+16
-1
@@ -4,6 +4,8 @@ import './styles/globals.css';
|
|||||||
import { authService } from './services/authService';
|
import { authService } from './services/authService';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Discover from './pages/Discover';
|
||||||
|
import Navigation from './components/Navigation';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -22,7 +24,12 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
return <Navigate to="/login" replace />;
|
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() {
|
export default function App() {
|
||||||
@@ -39,6 +46,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/discover"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Discover />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in new issue
Block a user