mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-23 15:08:04 +02:00
feat: implement backend episode management and progress tracking controllers and routes
This commit is contained in:
1 parent
686c4d9007
commit
aa54304bf3
9 files changed
+325
-30
No files matched your search
@@ -31,7 +31,24 @@ export const getLatestEpisodes = async (req: Request, res: Response) => {
|
||||
.limit(Number(limit))
|
||||
.skip(Number(skip));
|
||||
|
||||
res.json({ episodes, count: episodes.length });
|
||||
// Fetch progress for these episodes
|
||||
const episodeIds = episodes.map((e) => e._id);
|
||||
const progresses = await UserProgress.find({
|
||||
userId: req.user.id,
|
||||
episodeId: { $in: episodeIds },
|
||||
});
|
||||
|
||||
const episodesWithProgress = episodes.map((episode) => {
|
||||
const progress = progresses.find(
|
||||
(p) => p.episodeId.toString() === episode._id.toString()
|
||||
);
|
||||
return {
|
||||
...episode.toJSON(),
|
||||
progress: progress ? { position: progress.position, isCompleted: progress.isCompleted } : null,
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ episodes: episodesWithProgress, count: episodesWithProgress.length });
|
||||
} catch (error) {
|
||||
console.error('Get latest episodes error:', error);
|
||||
res.status(500).json({ message: 'Failed to fetch episodes' });
|
||||
@@ -48,7 +65,25 @@ export const getPodcastEpisodes = async (req: Request, res: Response) => {
|
||||
.limit(Number(limit))
|
||||
.skip(Number(skip));
|
||||
|
||||
res.json({ episodes, count: episodes.length });
|
||||
// If user is authenticated, fetch progress
|
||||
let episodesWithProgress = episodes.map(e => e.toJSON());
|
||||
if (req.user) {
|
||||
const progresses = await UserProgress.find({
|
||||
userId: req.user.id,
|
||||
episodeId: { $in: episodes.map(e => e._id) }
|
||||
});
|
||||
episodesWithProgress = episodes.map((episode) => {
|
||||
const progress = progresses.find(
|
||||
(p) => p.episodeId.toString() === episode._id.toString()
|
||||
);
|
||||
return {
|
||||
...episode.toJSON(),
|
||||
progress: progress ? { position: progress.position, isCompleted: progress.isCompleted } : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ episodes: episodesWithProgress, count: episodes.length });
|
||||
} catch (error) {
|
||||
console.error('Get podcast episodes error:', error);
|
||||
res.status(500).json({ message: 'Failed to fetch episodes' });
|
||||
@@ -144,3 +179,46 @@ export const getProgress = async (req: Request, res: Response) => {
|
||||
res.status(500).json({ message: 'Failed to fetch progress' });
|
||||
}
|
||||
};
|
||||
|
||||
export const searchEpisodes = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { q, limit = 40, skip = 0 } = req.query;
|
||||
|
||||
if (!q) {
|
||||
return res.status(400).json({ message: 'Search query required' });
|
||||
}
|
||||
|
||||
// Search episodes by title/description using text index
|
||||
const episodes = await Episode.find(
|
||||
{ $text: { $search: q as string } },
|
||||
{ score: { $meta: 'textScore' } }
|
||||
)
|
||||
.populate('podcastId', 'title author imageUrl')
|
||||
.sort({ score: { $meta: 'textScore' } })
|
||||
.limit(Number(limit))
|
||||
.skip(Number(skip));
|
||||
|
||||
// Fetch progress if authenticated
|
||||
let episodesWithProgress = episodes.map(e => e.toJSON());
|
||||
if (req.user) {
|
||||
const progresses = await UserProgress.find({
|
||||
userId: req.user.id,
|
||||
episodeId: { $in: episodes.map(e => e._id) }
|
||||
});
|
||||
episodesWithProgress = episodes.map((episode) => {
|
||||
const progress = progresses.find(
|
||||
(p) => p.episodeId.toString() === episode._id.toString()
|
||||
);
|
||||
return {
|
||||
...episode.toJSON(),
|
||||
progress: progress ? { position: progress.position, isCompleted: progress.isCompleted } : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ episodes: episodesWithProgress, count: episodesWithProgress.length });
|
||||
} catch (error) {
|
||||
console.error('Search episodes error:', error);
|
||||
res.status(500).json({ message: 'Search failed' });
|
||||
}
|
||||
};
|
||||
+26
-2
@@ -2,8 +2,11 @@ import express from 'express';
|
||||
import cors from 'cors';
|
||||
import 'dotenv/config';
|
||||
import path from 'path';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { connectDB } from './config/database';
|
||||
import { initRedis, closeRedis } from './config/redis';
|
||||
import { syncService } from './services/syncService';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
import authRoutes from './routes/authRoutes';
|
||||
import podcastRoutes from './routes/podcastRoutes';
|
||||
@@ -12,6 +15,26 @@ import episodeRoutes from './routes/episodeRoutes';
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 5000;
|
||||
|
||||
// Security Middleware
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: false, // Disable CSP for simplicity in this development phase
|
||||
}));
|
||||
|
||||
// Rate Limiting
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // limit each IP to 100 requests per window
|
||||
message: { message: 'Trop de requêtes, veuillez réessayer plus tard.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
const discoveryLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 50, // lower limit for heavy RSS operations
|
||||
message: { message: 'Limite de recherche atteinte pour cette heure.' },
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
@@ -45,8 +68,8 @@ app.get('/api/health', (req, res) => {
|
||||
});
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/podcasts', podcastRoutes);
|
||||
app.use('/api/episodes', episodeRoutes);
|
||||
app.use('/api/podcasts', apiLimiter, podcastRoutes);
|
||||
app.use('/api/episodes', apiLimiter, episodeRoutes);
|
||||
|
||||
// Serve frontend static files in production
|
||||
const frontendDist = path.join(__dirname, '..', 'public');
|
||||
@@ -79,6 +102,7 @@ process.on('SIGINT', gracefulShutdown);
|
||||
const startServer = async () => {
|
||||
try {
|
||||
await initialize();
|
||||
syncService.startBackgroundSync();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`\n${'='.repeat(50)}`);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getEpisode,
|
||||
saveProgress,
|
||||
getProgress,
|
||||
searchEpisodes,
|
||||
} from '../controllers/episodeController';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
|
||||
@@ -12,6 +13,7 @@ const router = Router();
|
||||
|
||||
// Protected routes
|
||||
router.get('/latest', authenticate, getLatestEpisodes);
|
||||
router.get('/search', authenticate, searchEpisodes);
|
||||
router.post('/progress', authenticate, saveProgress);
|
||||
router.get('/progress/:episodeId', authenticate, getProgress);
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import cron from 'node-cron';
|
||||
import { Podcast } from '../models/Podcast';
|
||||
import { rssParserService } from './rssParserService';
|
||||
|
||||
/**
|
||||
* Service to handle background synchronization of podcast feeds
|
||||
*/
|
||||
export const syncService = {
|
||||
/**
|
||||
* Initialize background sync jobs
|
||||
*/
|
||||
startBackgroundSync: () => {
|
||||
console.log('✓ Background Sync Engine started');
|
||||
|
||||
// Run every 4 hours
|
||||
// '0 */4 * * *'
|
||||
cron.schedule('0 */4 * * *', async () => {
|
||||
console.log(`[Cron] Starting global library sync: ${new Date().toISOString()}`);
|
||||
await syncService.syncAllPodcasts();
|
||||
});
|
||||
|
||||
// Run a quick sync for "Zero Episode" podcasts every hour
|
||||
// (Helpful for new additions that failed initial sync)
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
await syncService.syncEmptyPodcasts();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Sync all podcasts in the database
|
||||
*/
|
||||
syncAllPodcasts: async () => {
|
||||
try {
|
||||
const podcasts = await Podcast.find({});
|
||||
console.log(`[Sync Engine] Processing ${podcasts.length} podcasts...`);
|
||||
|
||||
for (const podcast of podcasts) {
|
||||
try {
|
||||
await rssParserService.syncPodcastEpisodes(podcast._id.toString(), podcast.rssUrl);
|
||||
// Wait 5 seconds between podcasts to avoid rate limiting from sources
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
} catch (error: any) {
|
||||
console.error(`[Sync Engine] Failed to sync ${podcast.title}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Sync Engine] Global sync completed');
|
||||
} catch (error: any) {
|
||||
console.error('[Sync Engine] Master sync error:', error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Specifically target podcasts with 0 episodes
|
||||
*/
|
||||
syncEmptyPodcasts: async () => {
|
||||
try {
|
||||
const emptyPodcasts = await Podcast.find({ episodeCount: 0 });
|
||||
if (emptyPodcasts.length === 0) return;
|
||||
|
||||
console.log(`[Sync Engine] Retrying ${emptyPodcasts.length} empty podcasts...`);
|
||||
for (const podcast of emptyPodcasts) {
|
||||
await rssParserService.syncPodcastEpisodes(podcast._id.toString(), podcast.rssUrl, true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[Sync Engine] Empty podcasts sync error:', error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user