feat: implement backend episode management and progress tracking controllers and routes

This commit is contained in:
Michael committed 2026-04-16 14:48:32 +02:00
1 parent 686c4d9007
commit aa54304bf3
9 files changed
+325 -30

No files matched your search

+80 -2
View File
@@ -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
View File
@@ -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)}`);
+2
View File
@@ -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);
+69
View File
@@ -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);
}
}
};