diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index f573c55..6b3a161 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -2,14 +2,37 @@ name: Build & Publish Docker Image
on:
push:
- branches: [main]
+ branches: [main, 'rust-*']
+ pull_request:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
+ test:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: backend
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Cache cargo
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: backend
+
+ - name: Run tests
+ run: cargo test
+
build-and-push:
+ needs: test
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
diff --git a/.gitignore b/.gitignore
index 1434d70..c84ce18 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,3 +40,6 @@ coverage/
# Docker
.dockerignore
+
+# Rust
+target/
diff --git a/Dockerfile b/Dockerfile
index 92be5d7..9f54c18 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,41 +8,43 @@ RUN npm install
COPY frontend/ ./
-# Build with /api as the API base URL (served by Express on same host)
+# Build with /api as the API base URL (served by the Rust backend on same host)
ENV VITE_API_URL=/api
RUN npm run build
-# Stage 2: Build backend
-FROM node:20-alpine AS backend-builder
+# Stage 2: Build Rust backend
+FROM rust:1-bookworm AS backend-builder
WORKDIR /app
-COPY backend/package*.json ./
-RUN npm install
+# Cache dependencies separately from sources
+COPY backend/Cargo.toml backend/Cargo.lock* ./
+RUN mkdir src && echo "fn main() {}" > src/main.rs \
+ && cargo build --release \
+ && rm -rf src
-COPY backend/tsconfig.json ./
COPY backend/src ./src
-
-RUN npm run build
+RUN touch src/main.rs && cargo build --release
# Stage 3: Production image
-FROM node:20-alpine
+FROM debian:bookworm-slim
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates \
+ && rm -rf /var/lib/apt/lists/* \
+ && useradd --system --no-create-home podcastic
WORKDIR /app
-# Copy backend production deps
-COPY backend/package*.json ./
-RUN npm install --omit=dev
-
-# Copy compiled backend
-COPY --from=backend-builder /app/dist ./dist
-
-# Copy frontend build into public/ (backend will serve it as static files)
+COPY --from=backend-builder /app/target/release/podcastic ./podcastic
COPY --from=frontend-builder /frontend/dist ./public
+USER podcastic
+
EXPOSE 3579
-ENV NODE_ENV=production
ENV PORT=3579
+ENV PUBLIC_DIR=/app/public
+ENV RUST_LOG=podcastic=info,tower_http=warn
-CMD ["node", "dist/index.js"]
+CMD ["/app/podcastic"]
diff --git a/README.md b/README.md
index 3ce193d..575ff28 100644
--- a/README.md
+++ b/README.md
@@ -63,8 +63,7 @@ docker-compose up -d
#### Backend
```bash
cd backend
-npm install
-npm run dev # Starts on port 5000
+cargo run # Starts on port 5000 (set PORT=5000)
```
#### Frontend
@@ -87,15 +86,9 @@ Podcastic/
│ │ └── styles/
│ └── Dockerfile
│
-├── backend/ # Express.js API
-│ ├── src/
-│ │ ├── routes/
-│ │ ├── controllers/
-│ │ ├── models/
-│ │ ├── middleware/
-│ │ ├── services/
-│ │ └── config/
-│ └── Dockerfile
+├── backend/ # Rust (axum) API
+│ ├── Cargo.toml
+│ └── src/ # main, auth, podcasts, episodes, feeds, itunes, db
│
├── docker-compose.yml
├── .env.example
diff --git a/UNRAID_INSTALL.md b/UNRAID_INSTALL.md
index 8876bc3..1f143d0 100644
--- a/UNRAID_INSTALL.md
+++ b/UNRAID_INSTALL.md
@@ -1,12 +1,12 @@
# Podcastic - Unraid Installation Guide
-Single container on port **3579** — includes backend, frontend, and serves everything via Express.
+Single container on port **3579** — includes backend, frontend, and serves everything via the Rust backend.
## What's Included
This single Docker container includes:
-- **Backend**: Node.js + Express.js API
-- **Frontend**: React web interface (served by Express as static files)
+- **Backend**: Rust (axum) API
+- **Frontend**: React web interface (served by the Rust backend as static files)
- **Database**: MongoDB (separate container, managed by Compose)
- **Cache**: Redis (separate container, managed by Compose)
@@ -55,7 +55,7 @@ Open: `http://your-unraid-ip:3579`
```
docker-compose services:
├── podcastic (port 3579)
-│ ├── Express.js API → /api/*
+│ ├── Rust (axum) API → /api/*
│ └── React frontend → /* (static files)
├── podcastic-mongodb → internal :27017
└── podcastic-redis → internal :6379
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
new file mode 100644
index 0000000..ead8461
--- /dev/null
+++ b/backend/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "podcastic"
+version = "2.0.0"
+edition = "2021"
+description = "Podcastic - Multi-user podcast streaming application backend"
+license = "MIT"
+
+[dependencies]
+anyhow = "1.0.86"
+axum = "0.7.5"
+bcrypt = "0.15.1"
+bson = { version = "2.11.0", features = ["chrono-0_4"] }
+chrono = { version = "0.4.38", features = ["serde"] }
+futures = "0.3.30"
+jsonwebtoken = "9.3.0"
+mongodb = "2.8.2"
+redis = { version = "0.25.4", features = ["tokio-comp", "connection-manager"] }
+reqwest = { version = "0.12.5", default-features = false, features = ["json", "rustls-tls", "gzip", "brotli", "deflate"] }
+rss = "2.0.8"
+serde = { version = "1.0.204", features = ["derive"] }
+serde_json = "1.0.120"
+tokio = { version = "1.38.0", features = ["full"] }
+tower-http = { version = "0.5.2", features = ["fs", "cors", "set-header", "trace"] }
+tracing = "0.1.40"
+tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
+
+[profile.release]
+opt-level = 3
+lto = "thin"
+codegen-units = 1
+strip = true
diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs
deleted file mode 100644
index ac6a21d..0000000
--- a/backend/eslint.config.mjs
+++ /dev/null
@@ -1,16 +0,0 @@
-import js from '@eslint/js';
-import tseslint from 'typescript-eslint';
-
-export default tseslint.config(
- js.configs.recommended,
- ...tseslint.configs.recommended,
- {
- rules: {
- '@typescript-eslint/no-unused-vars': 'warn',
- '@typescript-eslint/no-explicit-any': 'warn',
- },
- },
- {
- ignores: ['dist/**', 'node_modules/**'],
- }
-);
diff --git a/backend/package.json b/backend/package.json
deleted file mode 100644
index f9f1e24..0000000
--- a/backend/package.json
+++ /dev/null
@@ -1,45 +0,0 @@
-{
- "name": "podcastic-backend",
- "version": "1.0.0",
- "description": "Podcastic - Multi-user podcast streaming application backend",
- "main": "dist/index.js",
- "scripts": {
- "dev": "tsx watch src/index.ts",
- "build": "tsc",
- "start": "node dist/index.js",
- "lint": "eslint src",
- "test": "vitest"
- },
- "keywords": ["podcast", "streaming", "audio"],
- "author": "",
- "license": "MIT",
- "dependencies": {
- "express": "^4.18.2",
- "mongoose": "^7.5.0",
- "redis": "^4.6.11",
- "jsonwebtoken": "^9.0.0",
- "bcryptjs": "^2.4.3",
- "dotenv": "^16.3.1",
- "cors": "^2.8.5",
- "zod": "^3.22.4",
- "rss-parser": "^3.12.0",
- "axios": "^1.5.0",
- "node-cron": "^3.0.3",
- "helmet": "^7.1.0",
- "express-rate-limit": "^7.1.5"
- },
- "devDependencies": {
- "@types/express": "^4.17.20",
- "@types/node": "^20.8.0",
- "@types/cors": "^2.8.17",
- "@types/jsonwebtoken": "^9.0.4",
- "@types/bcryptjs": "^2.4.2",
- "@types/node-cron": "^3.0.11",
- "typescript": "^5.2.2",
- "tsx": "^4.1.0",
- "eslint": "^9.18.0",
- "@eslint/js": "^9.18.0",
- "typescript-eslint": "^8.22.0",
- "vitest": "^0.34.6"
- }
-}
diff --git a/backend/src/auth.rs b/backend/src/auth.rs
new file mode 100644
index 0000000..8b8b308
--- /dev/null
+++ b/backend/src/auth.rs
@@ -0,0 +1,303 @@
+use axum::{
+ async_trait,
+ extract::{FromRequestParts, State},
+ http::{header::AUTHORIZATION, request::Parts, StatusCode},
+ Json,
+};
+use bson::{doc, oid::ObjectId, Bson, Document};
+use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+
+use crate::{
+ db::{self, coll, USERS},
+ error::{internal, issue, ApiResult, AppError},
+ AppState,
+};
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct Claims {
+ pub id: String,
+ pub email: String,
+ pub iat: i64,
+ pub exp: i64,
+}
+
+pub struct JwtConfig {
+ encoding: EncodingKey,
+ decoding: DecodingKey,
+ access_ttl: i64,
+ refresh_ttl: i64,
+}
+
+impl JwtConfig {
+ pub fn new(secret: &str, access_ttl: &str, refresh_ttl: &str) -> Self {
+ Self {
+ encoding: EncodingKey::from_secret(secret.as_bytes()),
+ decoding: DecodingKey::from_secret(secret.as_bytes()),
+ access_ttl: parse_ttl(access_ttl).unwrap_or(7 * 86_400),
+ refresh_ttl: parse_ttl(refresh_ttl).unwrap_or(30 * 86_400),
+ }
+ }
+
+ fn sign(&self, id: &str, email: &str, ttl: i64) -> jsonwebtoken::errors::Result {
+ let iat = chrono::Utc::now().timestamp();
+ let claims = Claims {
+ id: id.to_owned(),
+ email: email.to_owned(),
+ iat,
+ exp: iat + ttl,
+ };
+ encode(&Header::new(Algorithm::HS256), &claims, &self.encoding)
+ }
+
+ pub fn issue_pair(&self, id: &str, email: &str) -> jsonwebtoken::errors::Result<(String, String)> {
+ Ok((
+ self.sign(id, email, self.access_ttl)?,
+ self.sign(id, email, self.refresh_ttl)?,
+ ))
+ }
+
+ pub fn verify(&self, token: &str) -> Option {
+ decode::(token, &self.decoding, &Validation::new(Algorithm::HS256))
+ .ok()
+ .map(|data| data.claims)
+ }
+}
+
+/// Parses `vercel/ms`-style durations used in env vars ("7d", "12h", "30m", "3600").
+fn parse_ttl(value: &str) -> Option {
+ let value = value.trim();
+ let split = value
+ .find(|c: char| !c.is_ascii_digit())
+ .unwrap_or(value.len());
+ let (amount, unit) = value.split_at(split);
+ let amount: i64 = amount.parse().ok()?;
+ let factor = match unit.trim() {
+ "" | "s" => 1,
+ "m" => 60,
+ "h" => 3_600,
+ "d" => 86_400,
+ "w" => 604_800,
+ "y" => 31_557_600,
+ _ => return None,
+ };
+ Some(amount * factor)
+}
+
+/// Authenticated user extracted from the `Authorization: Bearer` header.
+pub struct AuthUser {
+ pub id: ObjectId,
+}
+
+#[async_trait]
+impl FromRequestParts for AuthUser {
+ type Rejection = AppError;
+
+ async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result {
+ let token = parts
+ .headers
+ .get(AUTHORIZATION)
+ .and_then(|value| value.to_str().ok())
+ .and_then(|value| value.strip_prefix("Bearer "))
+ .ok_or_else(|| AppError::unauthorized("Missing or invalid authorization header"))?;
+
+ state
+ .jwt
+ .verify(token)
+ .and_then(|claims| db::oid(&claims.id))
+ .map(|id| AuthUser { id })
+ .ok_or_else(|| AppError::unauthorized("Invalid or expired token"))
+ }
+}
+
+/// Like [`AuthUser`] but never rejects the request.
+pub struct MaybeUser(pub Option);
+
+#[async_trait]
+impl FromRequestParts for MaybeUser {
+ type Rejection = AppError;
+
+ async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result {
+ Ok(MaybeUser(AuthUser::from_request_parts(parts, state).await.ok()))
+ }
+}
+
+fn str_field(body: &Value, key: &str) -> String {
+ body.get(key)
+ .and_then(Value::as_str)
+ .unwrap_or_default()
+ .to_owned()
+}
+
+fn is_valid_email(email: &str) -> bool {
+ let Some((local, domain)) = email.split_once('@') else {
+ return false;
+ };
+ !local.is_empty()
+ && !domain.contains('@')
+ && domain.contains('.')
+ && !domain.starts_with('.')
+ && !domain.ends_with('.')
+ && !email.chars().any(char::is_whitespace)
+}
+
+fn user_json(mut user: Document) -> Value {
+ user.remove("password");
+ db::doc_to_json(user)
+}
+
+fn auth_response(state: &AppState, user: Document, message: &str) -> ApiResult {
+ let id = user
+ .get_object_id("_id")
+ .map_err(internal("Authentication failed"))?
+ .to_hex();
+ let email = user.get_str("email").unwrap_or_default().to_owned();
+ let (access_token, refresh_token) = state
+ .jwt
+ .issue_pair(&id, &email)
+ .map_err(internal("Authentication failed"))?;
+
+ Ok(json!({
+ "message": message,
+ "user": user_json(user),
+ "tokens": { "accessToken": access_token, "refreshToken": refresh_token },
+ }))
+}
+
+pub async fn register(
+ State(state): State,
+ Json(body): Json,
+) -> ApiResult<(StatusCode, Json)> {
+ let email = str_field(&body, "email");
+ let password = str_field(&body, "password");
+ let username = str_field(&body, "username");
+
+ let mut errors = Vec::new();
+ if !is_valid_email(email.trim()) {
+ errors.push(issue("email", "Invalid email address"));
+ }
+ if password.chars().count() < 6 {
+ errors.push(issue("password", "Password must be at least 6 characters"));
+ }
+ if username.chars().count() < 2 {
+ errors.push(issue("username", "Username must be at least 2 characters"));
+ }
+ if !errors.is_empty() {
+ return Err(AppError::validation(errors));
+ }
+
+ let email = email.trim().to_lowercase();
+ let users = coll(&state.db, USERS);
+ let existing = users
+ .find_one(doc! { "email": email.as_str() }, None)
+ .await
+ .map_err(internal("Registration failed"))?;
+ if existing.is_some() {
+ return Err(AppError::new(StatusCode::CONFLICT, "Email already registered"));
+ }
+
+ let hash = tokio::task::spawn_blocking(move || bcrypt::hash(password, 10))
+ .await
+ .map_err(internal("Registration failed"))?
+ .map_err(internal("Registration failed"))?;
+
+ let now = db::now();
+ let mut user = doc! {
+ "email": email.as_str(),
+ "password": hash,
+ "username": username.trim(),
+ "avatar": Bson::Null,
+ "preferences": { "defaultPlaybackSpeed": 1, "theme": "light", "language": "en" },
+ "createdAt": now,
+ "updatedAt": now,
+ "__v": 0,
+ };
+ let inserted = users.insert_one(&user, None).await.map_err(|err| {
+ if db::is_duplicate_key(&err) {
+ AppError::new(StatusCode::CONFLICT, "Email already registered")
+ } else {
+ internal("Registration failed")(err)
+ }
+ })?;
+ user.insert("_id", inserted.inserted_id);
+
+ let body = auth_response(&state, user, "User registered successfully")?;
+ Ok((StatusCode::CREATED, Json(body)))
+}
+
+pub async fn login(State(state): State, Json(body): Json) -> ApiResult {
+ let email = str_field(&body, "email");
+ let password = str_field(&body, "password");
+
+ let mut errors = Vec::new();
+ if !is_valid_email(email.trim()) {
+ errors.push(issue("email", "Invalid email address"));
+ }
+ if password.is_empty() {
+ errors.push(issue("password", "Password required"));
+ }
+ if !errors.is_empty() {
+ return Err(AppError::validation(errors));
+ }
+
+ let invalid = || AppError::unauthorized("Invalid email or password");
+ let user = coll(&state.db, USERS)
+ .find_one(doc! { "email": email.trim().to_lowercase() }, None)
+ .await
+ .map_err(internal("Login failed"))?
+ .ok_or_else(invalid)?;
+
+ let hash = user.get_str("password").unwrap_or_default().to_owned();
+ let valid = tokio::task::spawn_blocking(move || bcrypt::verify(password, &hash).unwrap_or(false))
+ .await
+ .map_err(internal("Login failed"))?;
+ if !valid {
+ return Err(invalid());
+ }
+
+ Ok(Json(auth_response(&state, user, "Login successful")?))
+}
+
+pub async fn me(State(state): State, user: AuthUser) -> ApiResult {
+ let user = coll(&state.db, USERS)
+ .find_one(doc! { "_id": user.id }, None)
+ .await
+ .map_err(internal("Failed to fetch user"))?
+ .ok_or_else(|| AppError::not_found("User not found"))?;
+ Ok(Json(json!({ "user": user_json(user) })))
+}
+
+pub async fn logout(_user: AuthUser) -> Json {
+ // Stateless JWT: the client simply drops its tokens.
+ Json(json!({ "message": "Logout successful" }))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_ttl() {
+ assert_eq!(parse_ttl("7d"), Some(604_800));
+ assert_eq!(parse_ttl("15m"), Some(900));
+ assert_eq!(parse_ttl("3600"), Some(3600));
+ assert_eq!(parse_ttl("abc"), None);
+ }
+
+ #[test]
+ fn round_trips_tokens() {
+ let jwt = JwtConfig::new("secret", "7d", "30d");
+ let (access, _) = jwt.issue_pair("65f000000000000000000000", "a@b.co").unwrap();
+ let claims = jwt.verify(&access).unwrap();
+ assert_eq!(claims.email, "a@b.co");
+ assert!(jwt.verify("garbage").is_none());
+ }
+
+ #[test]
+ fn validates_email() {
+ assert!(is_valid_email("john@example.com"));
+ assert!(!is_valid_email("john@example"));
+ assert!(!is_valid_email("john.example.com"));
+ }
+}
diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts
deleted file mode 100644
index ce83538..0000000
--- a/backend/src/config/database.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import mongoose from 'mongoose';
-
-export const connectDB = async () => {
- try {
- const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/podcastic';
-
- await mongoose.connect(mongoUri, {
- retryWrites: true,
- w: 'majority',
- });
-
- console.log('✓ MongoDB connected successfully');
- return mongoose.connection;
- } catch (error) {
- console.error('✗ MongoDB connection failed:', error);
- process.exit(1);
- }
-};
-
-export const disconnectDB = async () => {
- try {
- await mongoose.disconnect();
- console.log('✓ MongoDB disconnected');
- } catch (error) {
- console.error('✗ MongoDB disconnection failed:', error);
- process.exit(1);
- }
-};
diff --git a/backend/src/config/redis.ts b/backend/src/config/redis.ts
deleted file mode 100644
index 81bb5f9..0000000
--- a/backend/src/config/redis.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { createClient } from 'redis';
-
-let redisClient: ReturnType | null = null;
-
-export const initRedis = async () => {
- try {
- const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
-
- redisClient = createClient({
- url: redisUrl,
- socket: {
- reconnectStrategy: (retries) => {
- if (retries > 10) {
- console.error('✗ Redis max retries reached');
- return new Error('Max retries');
- }
- return retries * 50;
- },
- },
- });
-
- redisClient.on('error', (err) => console.error('Redis error:', err));
- redisClient.on('connect', () => console.log('✓ Redis connected'));
- redisClient.on('reconnecting', () => console.log('→ Redis reconnecting...'));
-
- await redisClient.connect();
- return redisClient;
- } catch (error) {
- console.error('✗ Redis connection failed:', error);
- process.exit(1);
- }
-};
-
-export const getRedis = () => {
- if (!redisClient) {
- throw new Error('Redis not initialized');
- }
- return redisClient;
-};
-
-export const closeRedis = async () => {
- if (redisClient) {
- await redisClient.quit();
- console.log('✓ Redis connection closed');
- }
-};
diff --git a/backend/src/controllers/authController.ts b/backend/src/controllers/authController.ts
deleted file mode 100644
index 1d4e326..0000000
--- a/backend/src/controllers/authController.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import { Request, Response } from 'express';
-import { User } from '../models/User';
-import { generateTokens } from '../utils/jwt';
-import { z } from 'zod';
-
-const registerSchema = z.object({
- email: z.string().email('Invalid email address'),
- password: z.string().min(6, 'Password must be at least 6 characters'),
- username: z.string().min(2, 'Username must be at least 2 characters'),
-});
-
-const loginSchema = z.object({
- email: z.string().email('Invalid email address'),
- password: z.string().min(1, 'Password required'),
-});
-
-export const register = async (req: Request, res: Response) => {
- try {
- const { email, password, username } = registerSchema.parse(req.body);
-
- // Check if user already exists
- const existingUser = await User.findOne({ email });
- if (existingUser) {
- return res.status(409).json({ message: 'Email already registered' });
- }
-
- // Create new user
- const user = new User({
- email,
- password,
- username,
- });
-
- await user.save();
-
- // Generate tokens
- const { accessToken, refreshToken } = generateTokens(user);
-
- res.status(201).json({
- message: 'User registered successfully',
- user: user.toJSON(),
- tokens: { accessToken, refreshToken },
- });
- } catch (error) {
- if (error instanceof z.ZodError) {
- return res.status(400).json({
- message: 'Validation error',
- errors: error.errors,
- });
- }
-
- console.error('Register error:', error);
- res.status(500).json({ message: 'Registration failed' });
- }
-};
-
-export const login = async (req: Request, res: Response) => {
- try {
- const { email, password } = loginSchema.parse(req.body);
-
- // Find user
- const user = await User.findOne({ email });
- if (!user) {
- return res.status(401).json({ message: 'Invalid email or password' });
- }
-
- // Verify password
- const isPasswordValid = await user.comparePassword(password);
- if (!isPasswordValid) {
- return res.status(401).json({ message: 'Invalid email or password' });
- }
-
- // Generate tokens
- const { accessToken, refreshToken } = generateTokens(user);
-
- res.json({
- message: 'Login successful',
- user: user.toJSON(),
- tokens: { accessToken, refreshToken },
- });
- } catch (error) {
- if (error instanceof z.ZodError) {
- return res.status(400).json({
- message: 'Validation error',
- errors: error.errors,
- });
- }
-
- console.error('Login error:', error);
- res.status(500).json({ message: 'Login failed' });
- }
-};
-
-export const getMe = async (req: Request, res: Response) => {
- try {
- if (!req.user) {
- return res.status(401).json({ message: 'Not authenticated' });
- }
-
- const user = await User.findById(req.user.id);
- if (!user) {
- return res.status(404).json({ message: 'User not found' });
- }
-
- res.json({ user: user.toJSON() });
- } catch (error) {
- console.error('GetMe error:', error);
- res.status(500).json({ message: 'Failed to fetch user' });
- }
-};
-
-export const logout = async (req: Request, res: Response) => {
- try {
- // In a stateless JWT system, logout is client-side (delete token)
- // Could implement token blacklist with Redis if needed
- res.json({ message: 'Logout successful' });
- } catch (error) {
- console.error('Logout error:', error);
- res.status(500).json({ message: 'Logout failed' });
- }
-};
diff --git a/backend/src/controllers/episodeController.ts b/backend/src/controllers/episodeController.ts
deleted file mode 100644
index 0fd2a57..0000000
--- a/backend/src/controllers/episodeController.ts
+++ /dev/null
@@ -1,224 +0,0 @@
-import { Request, Response } from 'express';
-import { Episode } from '../models/Episode';
-import { UserProgress } from '../models/UserProgress';
-import { UserSubscription } from '../models/UserSubscription';
-
-export const getLatestEpisodes = async (req: Request, res: Response) => {
- try {
- if (!req.user) {
- return res.status(401).json({ message: 'Not authenticated' });
- }
-
- const { limit = 30, skip = 0 } = req.query;
-
- // Get user's subscribed podcasts
- const subscriptions = await UserSubscription.find({
- userId: req.user.id,
- }).select('podcastId');
-
- const podcastIds = subscriptions.map((sub) => sub.podcastId);
-
- if (podcastIds.length === 0) {
- return res.json({ episodes: [], count: 0 });
- }
-
- // Get latest episodes from subscribed podcasts
- const episodes = await Episode.find({
- podcastId: { $in: podcastIds },
- })
- .populate('podcastId', 'title author imageUrl')
- .sort({ pubDate: -1 })
- .limit(Number(limit))
- .skip(Number(skip));
-
- // 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' });
- }
-};
-
-export const getPodcastEpisodes = async (req: Request, res: Response) => {
- try {
- const { podcastId } = req.params;
- const { limit = 50, skip = 0 } = req.query;
-
- const episodes = await Episode.find({ podcastId })
- .sort({ pubDate: -1 })
- .limit(Number(limit))
- .skip(Number(skip));
-
- // 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' });
- }
-};
-
-export const getEpisode = async (req: Request, res: Response) => {
- try {
- const { episodeId } = req.params;
-
- const episode = await Episode.findById(episodeId).populate(
- 'podcastId',
- 'title author description imageUrl'
- );
-
- if (!episode) {
- return res.status(404).json({ message: 'Episode not found' });
- }
-
- res.json({ episode });
- } catch (error) {
- console.error('Get episode error:', error);
- res.status(500).json({ message: 'Failed to fetch episode' });
- }
-};
-
-export const saveProgress = async (req: Request, res: Response) => {
- try {
- if (!req.user) {
- return res.status(401).json({ message: 'Not authenticated' });
- }
-
- const { episodeId, position, isCompleted } = req.body;
-
- if (!episodeId || position === undefined) {
- return res.status(400).json({ message: 'Episode ID and position required' });
- }
-
- // Find or create progress record
- let progress = await UserProgress.findOne({
- userId: req.user.id,
- episodeId,
- });
-
- if (progress) {
- progress.position = position;
- if (isCompleted !== undefined) {
- progress.isCompleted = isCompleted;
- }
- progress.lastListenedAt = new Date();
- } else {
- progress = new UserProgress({
- userId: req.user.id,
- episodeId,
- position,
- isCompleted: isCompleted || false,
- lastListenedAt: new Date(),
- });
- }
-
- await progress.save();
-
- res.json({
- message: 'Progress saved',
- progress,
- });
- } catch (error) {
- console.error('Save progress error:', error);
- res.status(500).json({ message: 'Failed to save progress' });
- }
-};
-
-export const getProgress = async (req: Request, res: Response) => {
- try {
- if (!req.user) {
- return res.status(401).json({ message: 'Not authenticated' });
- }
-
- const { episodeId } = req.params;
-
- const progress = await UserProgress.findOne({
- userId: req.user.id,
- episodeId,
- });
-
- if (!progress) {
- return res.json({ progress: null, message: 'No progress found' });
- }
-
- res.json({ progress });
- } catch (error) {
- console.error('Get progress error:', error);
- 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' });
- }
-};
diff --git a/backend/src/controllers/podcastController.ts b/backend/src/controllers/podcastController.ts
deleted file mode 100644
index 1074674..0000000
--- a/backend/src/controllers/podcastController.ts
+++ /dev/null
@@ -1,328 +0,0 @@
-import { Request, Response } from 'express';
-import mongoose from 'mongoose';
-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.coerce.number().int().min(1).max(50).optional().default(20),
- offset: z.coerce.number().int().min(0).optional().default(0),
-});
-
-export const getUserSubscriptions = async (req: Request, res: Response) => {
- try {
- if (!req.user) {
- return res.status(401).json({ message: 'Not authenticated' });
- }
-
- const subscriptions = await UserSubscription.find({
- userId: req.user.id,
- })
- .populate('podcastId')
- .sort({ subscribedAt: -1 });
-
- const podcasts = subscriptions.map((sub) => {
- const podcastObj = (sub.podcastId as any).toJSON();
- return {
- ...podcastObj,
- subscriptionId: sub._id.toString(),
- };
- });
-
- res.json({ podcasts, count: podcasts.length });
- } catch (error) {
- console.error('Get subscriptions error:', error);
- res.status(500).json({ message: 'Failed to fetch subscriptions' });
- }
-};
-
-export const subscribe = async (req: Request, res: Response) => {
- try {
- if (!req.user) {
- return res.status(401).json({ message: 'Not authenticated' });
- }
-
- const { rssUrl } = subscribeSchema.parse(req.body);
-
- // Check if podcast exists
- let podcast = await Podcast.findOne({ rssUrl });
-
- if (!podcast) {
- // Parse RSS feed
- const feedData = await rssParserService.parseFeed(rssUrl);
- const episodes = rssParserService.parseEpisodes(feedData.episodes, feedData.imageUrl, rssUrl);
- try {
- const result = await rssParserService.createPodcastFromRss(rssUrl);
- podcast = await Podcast.findById(result.podcast._id);
- } catch (error: any) {
- if (error.message.includes('already in database')) {
- podcast = await Podcast.findOne({ rssUrl });
- } else {
- return res.status(400).json({
- message: `Impossible de récupérer le podcast : ${error.message}`,
- });
- }
- }
- }
-
- if (!podcast) {
- return res.status(400).json({ message: "Impossible d'ajouter le podcast" });
- }
-
- // Check if already subscribed
- const existingSubscription = await UserSubscription.findOne({
- userId: req.user.id,
- podcastId: podcast._id,
- });
-
- if (!existingSubscription) {
- const subscription = new UserSubscription({
- userId: req.user.id,
- podcastId: podcast._id,
- });
- await subscription.save();
- }
-
- res.status(200).json({
- message: existingSubscription ? 'Déjà abonné' : 'Abonnement réussi',
- podcast: podcast.toJSON(),
- });
- } catch (error) {
- if (error instanceof z.ZodError) {
- return res.status(400).json({
- message: 'Validation error',
- errors: error.errors,
- });
- }
-
- console.error('Subscribe error:', error);
- res.status(500).json({ message: "Impossible de s'abonner" });
- }
-};
-
-export const unsubscribe = async (req: Request, res: Response) => {
- try {
- if (!req.user) {
- return res.status(401).json({ message: 'Not authenticated' });
- }
-
- const { podcastId } = req.params;
-
- // Log the request
- console.log(`[Unsubscribe] User ${req.user.id} attempting to remove: ${podcastId}`);
-
- // Method 1: Try deletion by Subscription ID (New robust method)
- let subscription = await UserSubscription.findOneAndDelete({
- _id: new mongoose.Types.ObjectId(podcastId),
- userId: new mongoose.Types.ObjectId(req.user.id),
- });
-
- // Method 2: Fallback to Podcast ID if Method 1 found nothing (Old compatibility method)
- if (!subscription) {
- console.log(`[Unsubscribe] Not found by Subscription ID, trying by Podcast ID: ${podcastId}`);
- subscription = await UserSubscription.findOneAndDelete({
- userId: new mongoose.Types.ObjectId(req.user.id),
- podcastId: new mongoose.Types.ObjectId(podcastId),
- });
- }
-
- if (!subscription) {
- console.warn(`[Unsubscribe] No subscription record found for deletion.`);
- return res.status(404).json({ message: 'Subscription not found' });
- }
-
- // Cast to any to avoid TypeScript error with ModifyResult in logs
- const result = subscription as any;
- console.log(`[Unsubscribe] Successfully removed subscription ${result._id}`);
- res.json({ message: 'Unsubscribed successfully', deletedId: result._id });
- } catch (error) {
- console.error('Unsubscribe error:', error);
- res.status(500).json({ message: 'Failed to unsubscribe' });
- }
-};
-
-export const syncPodcast = async (req: Request, res: Response) => {
- try {
- if (!req.user) {
- return res.status(401).json({ message: 'Not authenticated' });
- }
-
- const { podcastId } = req.params;
- const podcast = await Podcast.findById(podcastId);
-
- if (!podcast) {
- return res.status(404).json({ message: 'Podcast not found' });
- }
-
- console.log(`[Sync] Triggering manual sync for podcast: ${podcast.title} (${podcastId})`);
- const result = await rssParserService.syncPodcastEpisodes(podcastId, podcast.rssUrl, true);
-
- res.json(result);
- } catch (error: any) {
- console.error('Manual sync error:', error);
- res.status(500).json({ message: error.message || 'Failed to sync podcast' });
- }
-};
-
-export const searchPodcasts = async (req: Request, res: Response) => {
- try {
- const { q, limit = 20, skip = 0 } = req.query;
-
- if (!q) {
- return res.status(400).json({ message: 'Search query required' });
- }
-
- const podcasts = await Podcast.find(
- { $text: { $search: q as string } },
- { score: { $meta: 'textScore' } }
- )
- .sort({ score: { $meta: 'textScore' } })
- .limit(Number(limit))
- .skip(Number(skip));
-
- res.json({ podcasts, count: podcasts.length });
- } catch (error) {
- console.error('Search error:', error);
- res.status(500).json({ message: 'Search failed' });
- }
-};
-
-export const getPodcast = async (req: Request, res: Response) => {
- try {
- const { podcastId } = req.params;
-
- const podcast = await Podcast.findById(podcastId);
- if (!podcast) {
- return res.status(404).json({ message: 'Podcast not found' });
- }
-
- res.json({ podcast });
- } catch (error) {
- console.error('Get podcast error:', error);
- res.status(500).json({ message: 'Failed to fetch podcast' });
- }
-};
-
-export const discoverPodcasts = async (req: Request, res: Response) => {
- try {
- const { q, limit = 20 } = searchSchema.parse(req.query);
- const query = q as string;
- const lim = limit as number;
-
- // 1. Try iTunes Search API
- try {
- const results = await podcastIndexService.searchPodcasts(query, lim);
- if (results.length > 0) {
- return res.json({ source: 'itunes', podcasts: results, count: results.length });
- }
- console.log(`[Discover] iTunes returned 0 results for "${query}", trying local DB`);
- } catch (itunesError: any) {
- console.error(`[Discover] iTunes failed for "${query}":`, itunesError.message);
- }
-
- // 2. Fallback: search local DB podcasts already in the system
- const localPodcasts = await Podcast.find({
- $or: [
- { title: { $regex: query, $options: 'i' } },
- { author: { $regex: query, $options: 'i' } },
- { description: { $regex: query, $options: 'i' } },
- ],
- }).limit(lim);
-
- const mapped = localPodcasts.map((p: any) => ({
- id: p._id.toString(),
- title: p.title,
- rssUrl: p.rssUrl,
- description: p.description || '',
- author: p.author || '',
- imageUrl: p.imageUrl,
- episodeCount: p.episodeCount,
- }));
-
- return res.json({ source: 'local', podcasts: mapped, count: mapped.length });
- } catch (error: any) {
- console.error('Discover podcasts error:', error.message);
- res.json({ source: 'itunes', podcasts: [], count: 0 });
- }
-};
-
-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, create it from RSS (parse feed for real episodes)
- if (!podcast) {
- try {
- const result = await rssParserService.createPodcastFromRss(rssUrl);
- // Refetch as a proper Mongoose document (result.podcast is a plain object)
- podcast = await Podcast.findById(result.podcast._id);
- } catch (error: any) {
- if (error.message.includes('already in database')) {
- podcast = await Podcast.findOne({ rssUrl });
- } else {
- return res.status(400).json({
- message: `Impossible de récupérer le podcast : ${error.message}`,
- });
- }
- }
- }
-
- if (!podcast) {
- return res.status(400).json({ message: "Impossible d'ajouter le podcast" });
- }
-
- // Check if already subscribed
- const existingSubscription = await UserSubscription.findOne({
- userId: req.user.id,
- podcastId: podcast._id,
- });
-
- if (!existingSubscription) {
- const subscription = new UserSubscription({
- userId: req.user.id,
- podcastId: podcast._id,
- });
- await subscription.save();
- }
-
- res.status(200).json({
- message: existingSubscription ? 'Déjà abonné' : 'Abonnement réussi',
- podcast: podcast.toJSON(),
- });
- } catch (error: any) {
- console.error('Subscribe from discovery error:', error);
- res.status(500).json({ message: "Impossible de s'abonner" });
- }
-};
-
-export const getTrendingPodcasts = async (req: Request, res: Response) => {
- try {
- const limit = Number(req.query.limit) || 20;
- const genre = req.query.genre as string;
- const trending = await podcastIndexService.getTrendingPodcasts(limit, genre);
- res.json({ source: 'itunes', podcasts: trending, count: trending.length });
- } catch (error: any) {
- console.error('Trending podcasts error:', error.message);
- // Return empty rather than crashing the page
- res.json({ source: 'itunes', podcasts: [], count: 0 });
- }
-};
diff --git a/backend/src/db.rs b/backend/src/db.rs
new file mode 100644
index 0000000..2f504ae
--- /dev/null
+++ b/backend/src/db.rs
@@ -0,0 +1,172 @@
+//! MongoDB access. Collection names and document shapes match the former
+//! Mongoose models so existing data keeps working after the Rust rewrite.
+
+use std::{collections::HashMap, time::Duration};
+
+use bson::{doc, oid::ObjectId, Bson, Document};
+use futures::TryStreamExt;
+use mongodb::{
+ error::{Error as MongoError, ErrorKind, WriteFailure},
+ options::{ClientOptions, FindOptions, IndexOptions},
+ Client, Collection, Database, IndexModel,
+};
+use serde_json::{Map, Value};
+
+pub const USERS: &str = "users";
+pub const PODCASTS: &str = "podcasts";
+pub const EPISODES: &str = "episodes";
+pub const PROGRESS: &str = "userprogresses";
+pub const SUBSCRIPTIONS: &str = "usersubscriptions";
+
+pub async fn connect(uri: &str) -> anyhow::Result {
+ let mut options = ClientOptions::parse(uri).await?;
+ options.app_name = Some("podcastic".into());
+ let client = Client::with_options(options)?;
+ let db = client
+ .default_database()
+ .unwrap_or_else(|| client.database("podcastic"));
+
+ let mut attempt = 1;
+ loop {
+ match db.run_command(doc! { "ping": 1 }, None).await {
+ Ok(_) => break,
+ Err(err) if attempt < 15 => {
+ tracing::warn!("MongoDB not ready (attempt {attempt}): {err}");
+ attempt += 1;
+ tokio::time::sleep(Duration::from_secs(2)).await;
+ }
+ Err(err) => return Err(err.into()),
+ }
+ }
+
+ ensure_indexes(&db).await;
+ tracing::info!("✓ MongoDB connected successfully");
+ Ok(db)
+}
+
+async fn ensure_indexes(db: &Database) {
+ let specs: Vec<(&str, Document, bool)> = vec![
+ (USERS, doc! { "email": 1 }, true),
+ (PODCASTS, doc! { "rssUrl": 1 }, true),
+ (PODCASTS, doc! { "title": "text", "description": "text", "author": "text" }, false),
+ (PODCASTS, doc! { "category": 1 }, false),
+ (EPISODES, doc! { "podcastId": 1, "guid": 1 }, true),
+ (EPISODES, doc! { "podcastId": 1 }, false),
+ (EPISODES, doc! { "pubDate": -1 }, false),
+ (EPISODES, doc! { "title": "text", "description": "text" }, false),
+ (PROGRESS, doc! { "userId": 1, "episodeId": 1 }, true),
+ (PROGRESS, doc! { "userId": 1, "lastListenedAt": -1 }, false),
+ (SUBSCRIPTIONS, doc! { "userId": 1, "podcastId": 1 }, true),
+ (SUBSCRIPTIONS, doc! { "userId": 1, "isFavorite": 1 }, false),
+ ];
+
+ for (name, keys, unique) in specs {
+ let options = unique.then(|| IndexOptions::builder().unique(true).build());
+ let model = IndexModel::builder().keys(keys).options(options).build();
+ // Indexes created earlier by Mongoose may differ slightly; that is fine.
+ if let Err(err) = coll(db, name).create_index(model, None).await {
+ tracing::debug!("Index on {name} skipped: {err}");
+ }
+ }
+}
+
+pub fn coll(db: &Database, name: &str) -> Collection {
+ db.collection::(name)
+}
+
+pub fn oid(value: &str) -> Option {
+ ObjectId::parse_str(value.trim()).ok()
+}
+
+pub fn now() -> bson::DateTime {
+ bson::DateTime::now()
+}
+
+pub fn iso(dt: bson::DateTime) -> String {
+ dt.to_chrono()
+ .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
+}
+
+pub async fn find_all(
+ collection: &Collection,
+ filter: Document,
+ options: impl Into
Second
]]>
+ Mon, 01 Jan 2024 10:00:00 +0000
+ ep-1
+
+ 01:02:03
+
+ -
+ Episode 2
+ Tue, 02 Jan 2024 10:00:00 CET
+
+
+ -
+ No audio
+
+
+"#;
+
+ #[test]
+ fn parses_feed() {
+ let channel = rss::Channel::read_from(FEED.as_bytes()).unwrap();
+ let feed = parse_channel(&channel).unwrap();
+ assert_eq!(feed.title, "Mon Podcast");
+ assert_eq!(feed.author, "Radio");
+ assert_eq!(feed.language, "fr");
+ assert_eq!(feed.image_url.as_deref(), Some("https://example.com/cover.jpg"));
+ assert_eq!(feed.episodes.len(), 2);
+
+ let newest = &feed.episodes[0];
+ assert_eq!(newest.title, "Episode 2");
+ assert_eq!(newest.audio_url, "https://example.com/2.mp3");
+ assert_eq!(newest.image_url.as_deref(), Some("https://example.com/cover.jpg"));
+
+ let first = &feed.episodes[1];
+ assert_eq!(first.guid, "ep-1");
+ assert_eq!(first.duration, 3723);
+ assert_eq!(first.description, "Hello & welcome\nSecond");
+ }
+
+ #[test]
+ fn parses_durations() {
+ assert_eq!(parse_duration("1:00:00"), 3600);
+ assert_eq!(parse_duration("05:30"), 330);
+ assert_eq!(parse_duration("1234"), 1234);
+ assert_eq!(parse_duration("abc"), 0);
+ }
+
+ #[test]
+ fn parses_dates() {
+ assert!(parse_date("Mon, 01 Jan 2024 10:00:00 GMT").is_some());
+ assert!(parse_date("Mon, 01 Jan 2024 10:00:00 CEST").is_some());
+ assert!(parse_date("2024-01-01T10:00:00Z").is_some());
+ assert!(parse_date("2024-01-01").is_some());
+ assert!(parse_date("not a date").is_none());
+ }
+
+ #[test]
+ fn finds_mp3_in_html() {
+ assert_eq!(
+ mp3_href(r#"dl"#).as_deref(),
+ Some("https://x.com/a.mp3?x=1")
+ );
+ assert_eq!(mp3_href(r#"x"#), None);
+ }
+}
diff --git a/backend/src/index.ts b/backend/src/index.ts
deleted file mode 100644
index 25937c3..0000000
--- a/backend/src/index.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-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';
-import episodeRoutes from './routes/episodeRoutes';
-import audioRoutes from './routes/audioRoutes';
-
-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());
-app.use(express.urlencoded({ limit: '10mb', extended: true }));
-
-// Initialize databases on startup
-let isInitialized = false;
-
-const initialize = async () => {
- if (isInitialized) return;
-
- try {
- await connectDB();
- await initRedis();
- isInitialized = true;
- console.log('✓ All services initialized');
- } catch (error) {
- console.error('✗ Initialization failed:', error);
- process.exit(1);
- }
-};
-
-// Health check endpoint (no auth required)
-app.get('/health', (req, res) => {
- res.json({ status: 'ok', timestamp: new Date().toISOString() });
-});
-
-// API Routes
-app.get('/api/health', (req, res) => {
- res.json({ status: 'ok', message: 'API is running' });
-});
-
-app.use('/api/auth', authRoutes);
-app.use('/api/podcasts', apiLimiter, podcastRoutes);
-app.use('/api/episodes', apiLimiter, episodeRoutes);
-app.use('/api/audio', audioRoutes);
-
-// Serve frontend static files in production
-const frontendDist = path.join(__dirname, '..', 'public');
-app.use(express.static(frontendDist));
-
-// SPA fallback — return index.html for any non-API route
-app.get('*', (req, res) => {
- const indexFile = path.join(frontendDist, 'index.html');
- res.sendFile(indexFile, (err) => {
- if (err) {
- res.status(404).json({ success: false, message: 'Not found' });
- }
- });
-});
-
-// Error handler (must be last)
-app.use(errorHandler);
-
-// Graceful shutdown
-const gracefulShutdown = async () => {
- console.log('\nShutting down gracefully...');
- await closeRedis();
- process.exit(0);
-};
-
-process.on('SIGTERM', gracefulShutdown);
-process.on('SIGINT', gracefulShutdown);
-
-// Start server
-const startServer = async () => {
- try {
- await initialize();
- syncService.startBackgroundSync();
-
- app.listen(PORT, () => {
- console.log(`\n${'='.repeat(50)}`);
- console.log(`🎙️ Podcastic Backend`);
- console.log(`Server running on http://localhost:${PORT}`);
- console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
- console.log(`${'='.repeat(50)}\n`);
- });
- } catch (error) {
- console.error('Failed to start server:', error);
- process.exit(1);
- }
-};
-
-startServer();
-
-export default app;
diff --git a/backend/src/itunes.rs b/backend/src/itunes.rs
new file mode 100644
index 0000000..1f44396
--- /dev/null
+++ b/backend/src/itunes.rs
@@ -0,0 +1,139 @@
+//! iTunes Search API — free, no API key required.
+
+use std::time::Duration;
+
+use serde::Serialize;
+use serde_json::Value;
+
+const TIMEOUT: Duration = Duration::from_secs(10);
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SearchResult {
+ pub id: String,
+ pub title: String,
+ pub rss_url: String,
+ pub description: String,
+ pub author: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub image_url: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub episode_count: Option,
+}
+
+fn str_of<'a>(item: &'a Value, key: &str) -> Option<&'a str> {
+ item.get(key)
+ .and_then(Value::as_str)
+ .filter(|s| !s.is_empty())
+}
+
+fn map_result(item: &Value) -> Option {
+ let rss_url = str_of(item, "feedUrl")?;
+ let id = match item.get("collectionId")? {
+ Value::Number(n) => n.to_string(),
+ Value::String(s) => s.clone(),
+ _ => return None,
+ };
+ Some(SearchResult {
+ id,
+ title: str_of(item, "collectionName").unwrap_or_default().to_owned(),
+ rss_url: rss_url.to_owned(),
+ description: str_of(item, "description")
+ .or_else(|| str_of(item, "primaryGenreName"))
+ .unwrap_or_default()
+ .to_owned(),
+ author: str_of(item, "artistName").unwrap_or_default().to_owned(),
+ image_url: str_of(item, "artworkUrl600")
+ .or_else(|| str_of(item, "artworkUrl100"))
+ .map(str::to_owned),
+ episode_count: item.get("trackCount").and_then(Value::as_i64),
+ })
+}
+
+fn map_results(body: &Value) -> Vec {
+ body.get("results")
+ .and_then(Value::as_array)
+ .map(|items| items.iter().filter_map(map_result).collect())
+ .unwrap_or_default()
+}
+
+pub async fn search(
+ http: &reqwest::Client,
+ query: &str,
+ limit: usize,
+) -> anyhow::Result> {
+ let term = query.trim();
+ if term.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ // Fetch more candidates than needed, then respect the requested limit.
+ let body: Value = http
+ .get("https://itunes.apple.com/search")
+ .query(&[
+ ("term", term),
+ ("media", "podcast"),
+ ("entity", "podcast"),
+ ("country", "FR"),
+ ("limit", "100"),
+ ])
+ .timeout(TIMEOUT)
+ .send()
+ .await?
+ .error_for_status()?
+ .json()
+ .await?;
+
+ let mut results = map_results(&body);
+ results.truncate(limit);
+ Ok(results)
+}
+
+pub async fn trending(
+ http: &reqwest::Client,
+ limit: usize,
+ genre: Option<&str>,
+) -> anyhow::Result> {
+ let cap = limit.clamp(1, 50);
+ let genre_path = genre
+ .filter(|g| !g.is_empty() && g.chars().all(|c| c.is_ascii_alphanumeric()))
+ .map(|g| format!("/genre={g}"))
+ .unwrap_or_default();
+
+ let feed: Value = http
+ .get(format!(
+ "https://itunes.apple.com/fr/rss/toppodcasts/limit={cap}{genre_path}/json"
+ ))
+ .timeout(TIMEOUT)
+ .send()
+ .await?
+ .error_for_status()?
+ .json()
+ .await?;
+
+ // A single entry is serialized as an object rather than an array.
+ let entries = match feed.pointer("/feed/entry") {
+ Some(Value::Array(entries)) => entries.clone(),
+ Some(entry @ Value::Object(_)) => vec![entry.clone()],
+ _ => Vec::new(),
+ };
+ let ids: Vec<&str> = entries
+ .iter()
+ .filter_map(|e| e.pointer("/id/attributes/im:id").and_then(Value::as_str))
+ .collect();
+ if ids.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let lookup: Value = http
+ .get("https://itunes.apple.com/lookup")
+ .query(&[("id", ids.join(",").as_str()), ("entity", "podcast")])
+ .timeout(TIMEOUT)
+ .send()
+ .await?
+ .error_for_status()?
+ .json()
+ .await?;
+
+ Ok(map_results(&lookup))
+}
diff --git a/backend/src/main.rs b/backend/src/main.rs
new file mode 100644
index 0000000..6716085
--- /dev/null
+++ b/backend/src/main.rs
@@ -0,0 +1,237 @@
+mod auth;
+mod db;
+mod episodes;
+mod error;
+mod feeds;
+mod itunes;
+mod podcasts;
+
+use std::{
+ collections::HashMap,
+ env,
+ net::{IpAddr, SocketAddr},
+ path::PathBuf,
+ sync::{Arc, Mutex},
+ time::{Duration, Instant},
+};
+
+use axum::{
+ extract::{ConnectInfo, Request, State},
+ http::{header, HeaderValue, StatusCode},
+ middleware::{self, Next},
+ response::{IntoResponse, Response},
+ routing::{delete, get, post},
+ Json, Router,
+};
+use mongodb::Database;
+use redis::aio::ConnectionManager;
+use serde_json::json;
+use tower_http::{
+ cors::CorsLayer,
+ services::{ServeDir, ServeFile},
+ set_header::SetResponseHeaderLayer,
+};
+
+use crate::auth::JwtConfig;
+
+#[derive(Clone)]
+pub struct AppState {
+ pub db: Database,
+ pub redis: ConnectionManager,
+ pub http: reqwest::Client,
+ pub jwt: Arc,
+ limiter: Arc,
+}
+
+/// Fixed-window, per-IP request limiter for the API routes.
+struct RateLimiter {
+ window: Duration,
+ max: u32,
+ hits: Mutex>,
+}
+
+impl RateLimiter {
+ fn new(window: Duration, max: u32) -> Self {
+ Self { window, max, hits: Mutex::new(HashMap::new()) }
+ }
+
+ fn allow(&self, ip: IpAddr) -> bool {
+ let now = Instant::now();
+ let mut hits = self.hits.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
+ if hits.len() > 10_000 {
+ hits.retain(|_, (start, _)| now.duration_since(*start) < self.window);
+ }
+ let entry = hits.entry(ip).or_insert((now, 0));
+ if now.duration_since(entry.0) >= self.window {
+ *entry = (now, 0);
+ }
+ entry.1 += 1;
+ entry.1 <= self.max
+ }
+}
+
+async fn rate_limit(
+ State(state): State,
+ ConnectInfo(addr): ConnectInfo,
+ request: Request,
+ next: Next,
+) -> Response {
+ if state.limiter.allow(addr.ip()) {
+ next.run(request).await
+ } else {
+ let body = json!({ "message": "Trop de requêtes, veuillez réessayer plus tard." });
+ (StatusCode::TOO_MANY_REQUESTS, Json(body)).into_response()
+ }
+}
+
+async fn connect_redis(url: &str) -> anyhow::Result {
+ let client = redis::Client::open(url)?;
+ let mut attempt = 1;
+ loop {
+ match ConnectionManager::new(client.clone()).await {
+ Ok(manager) => {
+ tracing::info!("✓ Redis connected");
+ return Ok(manager);
+ }
+ Err(err) if attempt < 15 => {
+ tracing::warn!("Redis not ready (attempt {attempt}): {err}");
+ attempt += 1;
+ tokio::time::sleep(Duration::from_secs(2)).await;
+ }
+ Err(err) => return Err(err.into()),
+ }
+ }
+}
+
+fn api_router(state: &AppState) -> Router {
+ let limited = || middleware::from_fn_with_state(state.clone(), rate_limit);
+
+ let auth = Router::new()
+ .route("/register", post(auth::register))
+ .route("/login", post(auth::login))
+ .route("/me", get(auth::me))
+ .route("/logout", post(auth::logout));
+
+ let podcasts = Router::new()
+ .route("/subscriptions", get(podcasts::subscriptions))
+ .route("/subscribe", post(podcasts::subscribe))
+ .route("/subscribe-discovery", post(podcasts::subscribe_discovery))
+ .route("/discover", get(podcasts::discover))
+ .route("/trending", get(podcasts::trending))
+ .route("/search", get(podcasts::search))
+ .route("/:id", get(podcasts::get_one))
+ .route("/:id/unsubscribe", delete(podcasts::unsubscribe))
+ .route("/:id/sync", post(podcasts::sync))
+ .layer(limited());
+
+ let episodes = Router::new()
+ .route("/latest", get(episodes::latest))
+ .route("/search", get(episodes::search))
+ .route("/progress", post(episodes::save_progress))
+ .route("/progress/:id", get(episodes::get_progress))
+ .route("/podcast/:id", get(episodes::for_podcast))
+ .route("/:id", get(episodes::get_one))
+ .layer(limited());
+
+ Router::new()
+ .route("/health", get(|| async { Json(json!({ "status": "ok", "message": "API is running" })) }))
+ .nest("/auth", auth)
+ .nest("/podcasts", podcasts)
+ .nest("/episodes", episodes)
+}
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+ tracing_subscriber::fmt()
+ .with_env_filter(
+ tracing_subscriber::EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("podcastic=info,tower_http=warn")),
+ )
+ .init();
+
+ let var = |key: &str, default: &str| env::var(key).unwrap_or_else(|_| default.to_owned());
+ let port: u16 = var("PORT", "3579").parse()?;
+ let jwt_secret = var("JWT_SECRET", "dev_secret_key");
+ if jwt_secret == "dev_secret_key" || jwt_secret == "change_me_in_production" {
+ tracing::warn!("JWT_SECRET uses a default value — set a strong secret in production");
+ }
+
+ let db = db::connect(&var("MONGODB_URI", "mongodb://localhost:27017/podcastic")).await?;
+ let redis = connect_redis(&var("REDIS_URL", "redis://localhost:6379")).await?;
+ let http = reqwest::Client::builder()
+ .user_agent(
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
+ )
+ .connect_timeout(Duration::from_secs(10))
+ .redirect(reqwest::redirect::Policy::limited(10))
+ .build()?;
+
+ let state = AppState {
+ db,
+ redis,
+ http,
+ jwt: Arc::new(JwtConfig::new(
+ &jwt_secret,
+ &var("JWT_EXPIRES_IN", "7d"),
+ &var("JWT_REFRESH_EXPIRES_IN", "30d"),
+ )),
+ limiter: Arc::new(RateLimiter::new(Duration::from_secs(15 * 60), 300)),
+ };
+
+ feeds::start_background_sync(state.clone());
+
+ // Frontend build served as static files with an SPA fallback.
+ let public = PathBuf::from(var("PUBLIC_DIR", "public"));
+ let spa = ServeDir::new(&public).fallback(ServeFile::new(public.join("index.html")));
+
+ let app = Router::new()
+ .route(
+ "/health",
+ get(|| async { Json(json!({ "status": "ok", "timestamp": chrono::Utc::now().to_rfc3339() })) }),
+ )
+ .nest("/api", api_router(&state))
+ .fallback_service(spa)
+ .layer(CorsLayer::permissive())
+ .layer(SetResponseHeaderLayer::overriding(
+ header::X_CONTENT_TYPE_OPTIONS,
+ HeaderValue::from_static("nosniff"),
+ ))
+ .layer(SetResponseHeaderLayer::overriding(
+ header::X_FRAME_OPTIONS,
+ HeaderValue::from_static("SAMEORIGIN"),
+ ))
+ .layer(SetResponseHeaderLayer::overriding(
+ header::REFERRER_POLICY,
+ HeaderValue::from_static("no-referrer"),
+ ))
+ .with_state(state);
+
+ let listener = tokio::net::TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port))).await?;
+ tracing::info!("🎙️ Podcastic (Rust) running on http://0.0.0.0:{port}");
+ axum::serve(listener, app.into_make_service_with_connect_info::())
+ .with_graceful_shutdown(shutdown_signal())
+ .await?;
+ Ok(())
+}
+
+async fn shutdown_signal() {
+ let ctrl_c = async {
+ let _ = tokio::signal::ctrl_c().await;
+ };
+ #[cfg(unix)]
+ let terminate = async {
+ if let Ok(mut signal) =
+ tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
+ {
+ signal.recv().await;
+ }
+ };
+ #[cfg(not(unix))]
+ let terminate = std::future::pending::<()>();
+
+ tokio::select! {
+ _ = ctrl_c => {},
+ _ = terminate => {},
+ }
+ tracing::info!("Shutting down gracefully...");
+}
diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts
deleted file mode 100644
index 7478e9a..0000000
--- a/backend/src/middleware/auth.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { Request, Response, NextFunction } from 'express';
-import { verifyAccessToken, JwtPayload } from '../utils/jwt';
-
-// Extend Express Request to include user info
-declare global {
- namespace Express {
- interface Request {
- user?: JwtPayload;
- }
- }
-}
-
-export const authenticate = (req: Request, res: Response, next: NextFunction) => {
- try {
- const authHeader = req.headers.authorization;
-
- if (!authHeader || !authHeader.startsWith('Bearer ')) {
- return res.status(401).json({ message: 'Missing or invalid authorization header' });
- }
-
- const token = authHeader.substring(7); // Remove 'Bearer ' prefix
-
- try {
- const decoded = verifyAccessToken(token);
- req.user = decoded;
- next();
- } catch (error) {
- return res.status(401).json({ message: 'Invalid or expired token' });
- }
- } catch (error) {
- return res.status(500).json({ message: 'Authentication error' });
- }
-};
-
-export const optional = (req: Request, res: Response, next: NextFunction) => {
- try {
- const authHeader = req.headers.authorization;
-
- if (authHeader && authHeader.startsWith('Bearer ')) {
- const token = authHeader.substring(7);
-
- try {
- const decoded = verifyAccessToken(token);
- req.user = decoded;
- } catch (error) {
- // Token invalid but optional, continue
- }
- }
-
- next();
- } catch (error) {
- next();
- }
-};
diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts
deleted file mode 100644
index adb0cbc..0000000
--- a/backend/src/middleware/errorHandler.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { Request, Response, NextFunction } from 'express';
-
-export interface AppError extends Error {
- statusCode?: number;
- details?: unknown;
-}
-
-export const errorHandler = (
- error: AppError,
- req: Request,
- res: Response,
- next: NextFunction
-) => {
- const statusCode = error.statusCode || 500;
- const message = error.message || 'Internal Server Error';
-
- console.error(`[${statusCode}] ${message}`, error.details || '');
-
- res.status(statusCode).json({
- success: false,
- message,
- ...(process.env.NODE_ENV === 'development' && { details: error.details }),
- });
-};
-
-export const notFoundHandler = (req: Request, res: Response) => {
- res.status(404).json({
- success: false,
- message: 'Route not found',
- });
-};
diff --git a/backend/src/models/Episode.ts b/backend/src/models/Episode.ts
deleted file mode 100644
index 0f97098..0000000
--- a/backend/src/models/Episode.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import mongoose, { Schema, Document } from 'mongoose';
-
-export interface IEpisode extends Document {
- podcastId: mongoose.Types.ObjectId;
- title: string;
- description: string;
- audioUrl: string;
- pubDate: Date;
- duration: number; // in seconds
- guid: string; // unique identifier from RSS
- imageUrl?: string;
- createdAt: Date;
- updatedAt: Date;
-}
-
-const episodeSchema = new Schema(
- {
- podcastId: {
- type: Schema.Types.ObjectId,
- ref: 'Podcast',
- required: true,
- index: true,
- },
- title: {
- type: String,
- required: true,
- trim: true,
- },
- description: {
- type: String,
- default: '',
- },
- audioUrl: {
- type: String,
- required: true,
- },
- pubDate: {
- type: Date,
- required: true,
- index: true,
- },
- duration: {
- type: Number,
- default: 0,
- },
- guid: {
- type: String,
- required: true,
- },
- imageUrl: {
- type: String,
- default: null,
- },
- },
- {
- timestamps: true,
- }
-);
-
-// Compound index for unique episode per podcast
-episodeSchema.index({ podcastId: 1, guid: 1 }, { unique: true });
-episodeSchema.index({ pubDate: -1 });
-episodeSchema.index({ title: 'text', description: 'text' });
-
-export const Episode = mongoose.model('Episode', episodeSchema);
diff --git a/backend/src/models/Podcast.ts b/backend/src/models/Podcast.ts
deleted file mode 100644
index 89e6135..0000000
--- a/backend/src/models/Podcast.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import mongoose, { Schema, Document } from 'mongoose';
-
-export interface IPodcast extends Document {
- title: string;
- description: string;
- rssUrl: string;
- imageUrl?: string;
- author: string;
- category: string[];
- language: string;
- lastFetched: Date;
- episodeCount: number;
- lastEpisodeTitle?: string;
- lastEpisodeDate?: Date;
- createdAt: Date;
- updatedAt: Date;
-}
-
-const podcastSchema = new Schema(
- {
- title: {
- type: String,
- required: true,
- trim: true,
- },
- description: {
- type: String,
- default: '',
- },
- rssUrl: {
- type: String,
- required: true,
- unique: true,
- trim: true,
- },
- imageUrl: {
- type: String,
- default: null,
- },
- author: {
- type: String,
- trim: true,
- },
- category: {
- type: [String],
- default: [],
- },
- language: {
- type: String,
- default: 'en',
- },
- lastFetched: {
- type: Date,
- default: () => new Date(0), // Start with epoch
- },
- episodeCount: {
- type: Number,
- default: 0,
- },
- lastEpisodeTitle: {
- type: String,
- default: '',
- },
- lastEpisodeDate: {
- type: Date,
- default: null,
- },
- },
- {
- timestamps: true,
- }
-);
-
-// Index for faster queries
-podcastSchema.index({ rssUrl: 1 });
-podcastSchema.index({ title: 'text', description: 'text', author: 'text' });
-podcastSchema.index({ category: 1 });
-
-export const Podcast = mongoose.model('Podcast', podcastSchema);
diff --git a/backend/src/models/User.ts b/backend/src/models/User.ts
deleted file mode 100644
index 95155b9..0000000
--- a/backend/src/models/User.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import mongoose, { Schema, Document } from 'mongoose';
-import bcrypt from 'bcryptjs';
-
-export interface IUser extends Document {
- email: string;
- password: string;
- username: string;
- avatar?: string;
- preferences: {
- defaultPlaybackSpeed: number;
- theme: 'light' | 'dark';
- language: string;
- };
- createdAt: Date;
- updatedAt: Date;
- comparePassword(password: string): Promise;
-}
-
-const userSchema = new Schema(
- {
- email: {
- type: String,
- required: true,
- unique: true,
- lowercase: true,
- trim: true,
- },
- password: {
- type: String,
- required: true,
- minlength: 6,
- },
- username: {
- type: String,
- required: true,
- trim: true,
- },
- avatar: {
- type: String,
- default: null,
- },
- preferences: {
- defaultPlaybackSpeed: {
- type: Number,
- default: 1,
- enum: [0.75, 1, 1.25, 1.5, 1.75, 2],
- },
- theme: {
- type: String,
- default: 'light',
- enum: ['light', 'dark'],
- },
- language: {
- type: String,
- default: 'en',
- },
- },
- },
- {
- timestamps: true,
- }
-);
-
-// Hash password before saving
-userSchema.pre('save', async function (next) {
- if (!this.isModified('password')) return next();
-
- try {
- const salt = await bcrypt.genSalt(10);
- this.password = await bcrypt.hash(this.password, salt);
- next();
- } catch (error) {
- next(error as Error);
- }
-});
-
-// Method to compare passwords
-userSchema.methods.comparePassword = async function (password: string): Promise {
- return bcrypt.compare(password, this.password);
-};
-
-// Remove password from JSON output
-userSchema.methods.toJSON = function () {
- const obj = this.toObject();
- delete obj.password;
- return obj;
-};
-
-export const User = mongoose.model('User', userSchema);
diff --git a/backend/src/models/UserProgress.ts b/backend/src/models/UserProgress.ts
deleted file mode 100644
index d70dd5c..0000000
--- a/backend/src/models/UserProgress.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import mongoose, { Schema, Document } from 'mongoose';
-
-export interface IUserProgress extends Document {
- userId: mongoose.Types.ObjectId;
- episodeId: mongoose.Types.ObjectId;
- position: number; // seconds listened
- isCompleted: boolean;
- isSaved: boolean;
- lastListenedAt: Date;
- createdAt: Date;
- updatedAt: Date;
-}
-
-const userProgressSchema = new Schema(
- {
- userId: {
- type: Schema.Types.ObjectId,
- ref: 'User',
- required: true,
- index: true,
- },
- episodeId: {
- type: Schema.Types.ObjectId,
- ref: 'Episode',
- required: true,
- index: true,
- },
- position: {
- type: Number,
- default: 0,
- min: 0,
- },
- isCompleted: {
- type: Boolean,
- default: false,
- },
- isSaved: {
- type: Boolean,
- default: false,
- },
- lastListenedAt: {
- type: Date,
- default: () => new Date(),
- },
- },
- {
- timestamps: true,
- }
-);
-
-// Compound index for unique progress per user per episode
-userProgressSchema.index({ userId: 1, episodeId: 1 }, { unique: true });
-userProgressSchema.index({ userId: 1, lastListenedAt: -1 });
-userProgressSchema.index({ userId: 1, isCompleted: 1 });
-userProgressSchema.index({ userId: 1, isSaved: 1 });
-
-export const UserProgress = mongoose.model('UserProgress', userProgressSchema);
diff --git a/backend/src/models/UserSubscription.ts b/backend/src/models/UserSubscription.ts
deleted file mode 100644
index 58403b9..0000000
--- a/backend/src/models/UserSubscription.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import mongoose, { Schema, Document } from 'mongoose';
-
-export interface IUserSubscription extends Document {
- userId: mongoose.Types.ObjectId;
- podcastId: mongoose.Types.ObjectId;
- subscribedAt: Date;
- isFavorite: boolean;
- createdAt: Date;
- updatedAt: Date;
-}
-
-const userSubscriptionSchema = new Schema(
- {
- userId: {
- type: Schema.Types.ObjectId,
- ref: 'User',
- required: true,
- index: true,
- },
- podcastId: {
- type: Schema.Types.ObjectId,
- ref: 'Podcast',
- required: true,
- index: true,
- },
- subscribedAt: {
- type: Date,
- default: () => new Date(),
- },
- isFavorite: {
- type: Boolean,
- default: false,
- },
- },
- {
- timestamps: true,
- }
-);
-
-// Compound index to ensure unique subscription per user
-userSubscriptionSchema.index({ userId: 1, podcastId: 1 }, { unique: true });
-userSubscriptionSchema.index({ userId: 1, isFavorite: 1 });
-
-export const UserSubscription = mongoose.model(
- 'UserSubscription',
- userSubscriptionSchema
-);
diff --git a/backend/src/podcasts.rs b/backend/src/podcasts.rs
new file mode 100644
index 0000000..e0f037b
--- /dev/null
+++ b/backend/src/podcasts.rs
@@ -0,0 +1,311 @@
+use std::collections::HashMap;
+
+use axum::{
+ extract::{Path, Query, State},
+ http::StatusCode,
+ Json,
+};
+use bson::{doc, oid::ObjectId, Document};
+use mongodb::options::FindOptions;
+use serde_json::{json, Value};
+
+use crate::{
+ auth::AuthUser,
+ db::{self, coll, find_all, PODCASTS, SUBSCRIPTIONS},
+ error::{internal, issue, ApiResult, AppError},
+ feeds, itunes, AppState,
+};
+
+type Params = Query>;
+
+/// `Number(value) || default` semantics for pagination parameters.
+pub fn num_param(params: &HashMap, key: &str, default: i64) -> i64 {
+ params
+ .get(key)
+ .and_then(|v| v.trim().parse::().ok())
+ .filter(|v| v.is_finite() && *v > 0.0)
+ .map(|v| v as i64)
+ .unwrap_or(default)
+}
+
+fn escape_regex(text: &str) -> String {
+ let mut out = String::with_capacity(text.len());
+ for c in text.chars() {
+ if "\\^$.|?*+()[]{}/".contains(c) {
+ out.push('\\');
+ }
+ out.push(c);
+ }
+ out
+}
+
+pub async fn subscriptions(State(state): State, user: AuthUser) -> ApiResult {
+ let options = FindOptions::builder().sort(doc! { "subscribedAt": -1 }).build();
+ let subs = find_all(&coll(&state.db, SUBSCRIPTIONS), doc! { "userId": user.id }, options)
+ .await
+ .map_err(internal("Failed to fetch subscriptions"))?;
+
+ let ids: Vec = subs
+ .iter()
+ .filter_map(|s| s.get_object_id("podcastId").ok())
+ .collect();
+ let mut podcasts: HashMap =
+ find_all(&coll(&state.db, PODCASTS), doc! { "_id": { "$in": ids } }, None)
+ .await
+ .map_err(internal("Failed to fetch subscriptions"))?
+ .into_iter()
+ .filter_map(|p| Some((p.get_object_id("_id").ok()?, p)))
+ .collect();
+
+ let list: Vec = subs
+ .iter()
+ .filter_map(|sub| {
+ let mut podcast = podcasts.remove(&sub.get_object_id("podcastId").ok()?)?;
+ podcast.insert("subscriptionId", sub.get_object_id("_id").ok()?.to_hex());
+ Some(db::doc_to_json(podcast))
+ })
+ .collect();
+
+ Ok(Json(json!({ "count": list.len(), "podcasts": list })))
+}
+
+async fn subscribe_user(state: &AppState, user: &AuthUser, rss_url: &str) -> ApiResult {
+ let podcasts = coll(&state.db, PODCASTS);
+ let existing = podcasts
+ .find_one(doc! { "rssUrl": rss_url }, None)
+ .await
+ .map_err(internal("Impossible de s'abonner"))?;
+
+ let podcast = match existing {
+ Some(podcast) => podcast,
+ None => feeds::create_podcast_from_rss(state, rss_url)
+ .await
+ .map_err(|err| {
+ AppError::bad_request(format!("Impossible de récupérer le podcast : {err}"))
+ })?,
+ };
+ let podcast_id = podcast
+ .get_object_id("_id")
+ .map_err(internal("Impossible de s'abonner"))?;
+
+ let subs = coll(&state.db, SUBSCRIPTIONS);
+ let filter = doc! { "userId": user.id, "podcastId": podcast_id };
+ let already = subs
+ .find_one(filter, None)
+ .await
+ .map_err(internal("Impossible de s'abonner"))?
+ .is_some();
+
+ if !already {
+ let now = db::now();
+ let subscription = doc! {
+ "userId": user.id,
+ "podcastId": podcast_id,
+ "subscribedAt": now,
+ "isFavorite": false,
+ "createdAt": now,
+ "updatedAt": now,
+ "__v": 0,
+ };
+ if let Err(err) = subs.insert_one(subscription, None).await {
+ if !db::is_duplicate_key(&err) {
+ return Err(internal("Impossible de s'abonner")(err));
+ }
+ }
+ }
+
+ Ok(Json(json!({
+ "message": if already { "Déjà abonné" } else { "Abonnement réussi" },
+ "podcast": db::doc_to_json(podcast),
+ })))
+}
+
+pub async fn subscribe(
+ State(state): State,
+ user: AuthUser,
+ Json(body): Json,
+) -> ApiResult {
+ let rss_url = body
+ .get("rssUrl")
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .unwrap_or_default();
+ if reqwest::Url::parse(rss_url).is_err() {
+ return Err(AppError::validation(vec![issue("rssUrl", "Invalid RSS URL")]));
+ }
+ subscribe_user(&state, &user, rss_url).await
+}
+
+pub async fn subscribe_discovery(
+ State(state): State,
+ user: AuthUser,
+ Json(body): Json,
+) -> ApiResult {
+ let rss_url = body
+ .get("rssUrl")
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .unwrap_or_default();
+ if rss_url.is_empty() {
+ return Err(AppError::bad_request("RSS URL required"));
+ }
+ subscribe_user(&state, &user, rss_url).await
+}
+
+pub async fn unsubscribe(
+ State(state): State,
+ user: AuthUser,
+ Path(id): Path,
+) -> ApiResult {
+ let not_found = || AppError::not_found("Subscription not found");
+ let id = db::oid(&id).ok_or_else(not_found)?;
+ let subs = coll(&state.db, SUBSCRIPTIONS);
+
+ // The frontend may send either the subscription id or the podcast id.
+ let mut deleted = subs
+ .find_one_and_delete(doc! { "_id": id, "userId": user.id }, None)
+ .await
+ .map_err(internal("Failed to unsubscribe"))?;
+ if deleted.is_none() {
+ deleted = subs
+ .find_one_and_delete(doc! { "userId": user.id, "podcastId": id }, None)
+ .await
+ .map_err(internal("Failed to unsubscribe"))?;
+ }
+ let deleted = deleted.ok_or_else(not_found)?;
+ let deleted_id = deleted.get_object_id("_id").map(|id| id.to_hex()).unwrap_or_default();
+ tracing::info!("[Unsubscribe] Removed subscription {deleted_id}");
+
+ Ok(Json(json!({ "message": "Unsubscribed successfully", "deletedId": deleted_id })))
+}
+
+pub async fn sync(
+ State(state): State,
+ _user: AuthUser,
+ Path(id): Path,
+) -> ApiResult {
+ let not_found = || AppError::not_found("Podcast not found");
+ let id = db::oid(&id).ok_or_else(not_found)?;
+ let podcast = coll(&state.db, PODCASTS)
+ .find_one(doc! { "_id": id }, None)
+ .await
+ .map_err(internal("Failed to sync podcast"))?
+ .ok_or_else(not_found)?;
+ let rss_url = podcast.get_str("rssUrl").unwrap_or_default();
+
+ tracing::info!("[Sync] Manual sync for {}", podcast.get_str("title").unwrap_or_default());
+ feeds::sync_podcast(&state, id, rss_url, true)
+ .await
+ .map(Json)
+ .map_err(|err| {
+ tracing::error!("Manual sync error: {err}");
+ AppError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
+ })
+}
+
+pub async fn search(State(state): State, Query(params): Params) -> ApiResult {
+ let query = params.get("q").map(String::as_str).unwrap_or_default();
+ if query.is_empty() {
+ return Err(AppError::bad_request("Search query required"));
+ }
+ let score = doc! { "score": { "$meta": "textScore" } };
+ let options = FindOptions::builder()
+ .projection(score.clone())
+ .sort(score)
+ .skip(num_param(¶ms, "skip", 0) as u64)
+ .limit(num_param(¶ms, "limit", 20))
+ .build();
+ let podcasts: Vec = find_all(
+ &coll(&state.db, PODCASTS),
+ doc! { "$text": { "$search": query } },
+ options,
+ )
+ .await
+ .map_err(internal("Search failed"))?
+ .into_iter()
+ .map(db::doc_to_json)
+ .collect();
+
+ Ok(Json(json!({ "count": podcasts.len(), "podcasts": podcasts })))
+}
+
+pub async fn get_one(State(state): State, Path(id): Path) -> ApiResult {
+ let not_found = || AppError::not_found("Podcast not found");
+ let id = db::oid(&id).ok_or_else(not_found)?;
+ let podcast = coll(&state.db, PODCASTS)
+ .find_one(doc! { "_id": id }, None)
+ .await
+ .map_err(internal("Failed to fetch podcast"))?
+ .ok_or_else(not_found)?;
+ Ok(Json(json!({ "podcast": db::doc_to_json(podcast) })))
+}
+
+fn empty_discovery() -> Json {
+ Json(json!({ "source": "itunes", "podcasts": [], "count": 0 }))
+}
+
+pub async fn discover(State(state): State, Query(params): Params) -> Json {
+ let query = params.get("q").map(String::as_str).unwrap_or_default();
+ let limit = match params.get("limit").map(|l| l.trim().parse::()) {
+ None => 20,
+ Some(Ok(limit)) if (1..=50).contains(&limit) => limit,
+ Some(_) => return empty_discovery(),
+ };
+ if query.chars().count() < 2 {
+ return empty_discovery();
+ }
+
+ match itunes::search(&state.http, query, limit as usize).await {
+ Ok(results) if !results.is_empty() => {
+ return Json(json!({ "source": "itunes", "count": results.len(), "podcasts": results }));
+ }
+ Ok(_) => tracing::info!("[Discover] iTunes returned 0 results for \"{query}\", trying local DB"),
+ Err(err) => tracing::error!("[Discover] iTunes failed for \"{query}\": {err}"),
+ }
+
+ // Fallback: podcasts already known locally.
+ let pattern = escape_regex(query);
+ let filter = doc! { "$or": [
+ { "title": { "$regex": pattern.as_str(), "$options": "i" } },
+ { "author": { "$regex": pattern.as_str(), "$options": "i" } },
+ { "description": { "$regex": pattern.as_str(), "$options": "i" } },
+ ] };
+ let options = FindOptions::builder().limit(limit).build();
+ let local = match find_all(&coll(&state.db, PODCASTS), filter, options).await {
+ Ok(local) => local,
+ Err(err) => {
+ tracing::error!("Discover podcasts error: {err}");
+ return empty_discovery();
+ }
+ };
+
+ let mapped: Vec = local
+ .into_iter()
+ .map(|p| {
+ let p = db::doc_to_json(p);
+ json!({
+ "id": p["_id"],
+ "title": p["title"],
+ "rssUrl": p["rssUrl"],
+ "description": p.get("description").cloned().unwrap_or_else(|| json!("")),
+ "author": p.get("author").cloned().unwrap_or_else(|| json!("")),
+ "imageUrl": p["imageUrl"],
+ "episodeCount": p["episodeCount"],
+ })
+ })
+ .collect();
+
+ Json(json!({ "source": "local", "count": mapped.len(), "podcasts": mapped }))
+}
+
+pub async fn trending(State(state): State, Query(params): Params) -> Json {
+ let limit = num_param(¶ms, "limit", 20) as usize;
+ let genre = params.get("genre").map(String::as_str);
+ match itunes::trending(&state.http, limit, genre).await {
+ Ok(podcasts) => Json(json!({ "source": "itunes", "count": podcasts.len(), "podcasts": podcasts })),
+ Err(err) => {
+ tracing::error!("Trending podcasts error: {err}");
+ empty_discovery()
+ }
+ }
+}
diff --git a/backend/src/routes/audioRoutes.ts b/backend/src/routes/audioRoutes.ts
deleted file mode 100644
index 960c7f7..0000000
--- a/backend/src/routes/audioRoutes.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import { Router, Request, Response } from 'express';
-import https from 'https';
-import http from 'http';
-import { IncomingMessage } from 'http';
-
-const router = Router();
-
-const BLOCKED = [
- /^127\./,
- /^10\./,
- /^192\.168\./,
- /^172\.(1[6-9]|2\d|3[01])\./,
- /^::1$/,
- /^localhost$/i,
-];
-
-function isBlocked(hostname: string): boolean {
- return BLOCKED.some(r => r.test(hostname));
-}
-
-// Keep-alive agents reuse TLS connections across requests
-const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 32 });
-const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 32 });
-
-// Cache resolved final URLs (skip redirect chain on range/seek requests)
-// TTL: 10 minutes
-const urlCache = new Map();
-const URL_CACHE_TTL = 10 * 60 * 1000;
-
-function getCachedUrl(original: string): string | null {
- const entry = urlCache.get(original);
- if (!entry) return null;
- if (Date.now() > entry.expiresAt) { urlCache.delete(original); return null; }
- return entry.finalUrl;
-}
-
-function setCachedUrl(original: string, finalUrl: string): void {
- urlCache.set(original, { finalUrl, expiresAt: Date.now() + URL_CACHE_TTL });
-}
-
-const REDIRECT_CODES = new Set([301, 302, 303, 307, 308]);
-
-function fetchAudio(
- url: string,
- originalUrl: string,
- headers: Record,
- res: Response,
- depth: number,
-): void {
- if (depth > 8) {
- if (!res.headersSent) res.status(502).json({ message: 'Too many redirects' });
- return;
- }
-
- let parsed: URL;
- try {
- parsed = new URL(url);
- } catch {
- if (!res.headersSent) res.status(400).json({ message: 'Invalid redirect URL' });
- return;
- }
-
- if (!['http:', 'https:'].includes(parsed.protocol)) {
- if (!res.headersSent) res.status(400).json({ message: 'Protocol not allowed' });
- return;
- }
- if (isBlocked(parsed.hostname)) {
- if (!res.headersSent) res.status(403).json({ message: 'Forbidden' });
- return;
- }
-
- const agent = parsed.protocol === 'https:' ? httpsAgent : httpAgent;
- const lib = parsed.protocol === 'https:' ? https : http;
-
- const proxyReq = lib.request(url, { headers, agent }, (proxyRes: IncomingMessage) => {
- const status = proxyRes.statusCode ?? 200;
-
- if (REDIRECT_CODES.has(status) && proxyRes.headers.location) {
- const next = new URL(proxyRes.headers.location, url).toString();
- proxyRes.resume();
- fetchAudio(next, originalUrl, headers, res, depth + 1);
- return;
- }
-
- // Cache resolved URL so next range request skips redirect chain
- if (depth > 0 && originalUrl !== url) {
- setCachedUrl(originalUrl, url);
- }
-
- res.setHeader('Access-Control-Allow-Origin', '*');
- res.setHeader('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Content-Type, Accept-Ranges');
-
- const forward = ['content-type', 'content-length', 'content-range', 'accept-ranges'];
- for (const h of forward) {
- const v = proxyRes.headers[h];
- if (v) res.setHeader(h, v as string);
- }
-
- res.status(status);
- proxyRes.pipe(res);
- });
-
- proxyReq.on('error', () => {
- if (!res.headersSent) res.status(502).json({ message: 'Proxy error' });
- });
-
- proxyReq.end();
-}
-
-router.get('/proxy', (req: Request, res: Response) => {
- const raw = req.query.url as string;
- if (!raw) { res.status(400).json({ message: 'url required' }); return; }
-
- let parsed: URL;
- try {
- parsed = new URL(raw);
- } catch {
- res.status(400).json({ message: 'Invalid URL' }); return;
- }
-
- if (!['http:', 'https:'].includes(parsed.protocol)) {
- res.status(400).json({ message: 'Protocol not allowed' }); return;
- }
- if (isBlocked(parsed.hostname)) {
- res.status(403).json({ message: 'Forbidden' }); return;
- }
-
- const upstream: Record = {
- 'User-Agent': 'Mozilla/5.0 (compatible; Podcastic/1.0)',
- 'Accept': '*/*',
- };
- if (req.headers.range) upstream['Range'] = req.headers.range as string;
-
- req.on('close', () => res.destroy());
-
- // Use cached final URL if available (skips redirect chain — critical for seeks)
- const startUrl = getCachedUrl(raw) ?? raw;
- fetchAudio(startUrl, raw, upstream, res, 0);
-});
-
-export default router;
diff --git a/backend/src/routes/authRoutes.ts b/backend/src/routes/authRoutes.ts
deleted file mode 100644
index fc872d7..0000000
--- a/backend/src/routes/authRoutes.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { Router } from 'express';
-import { register, login, getMe, logout } from '../controllers/authController';
-import { authenticate } from '../middleware/auth';
-
-const router = Router();
-
-// Public routes
-router.post('/register', register);
-router.post('/login', login);
-
-// Protected routes
-router.get('/me', authenticate, getMe);
-router.post('/logout', authenticate, logout);
-
-export default router;
diff --git a/backend/src/routes/episodeRoutes.ts b/backend/src/routes/episodeRoutes.ts
deleted file mode 100644
index cb80095..0000000
--- a/backend/src/routes/episodeRoutes.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { Router } from 'express';
-import {
- getLatestEpisodes,
- getPodcastEpisodes,
- getEpisode,
- saveProgress,
- getProgress,
- searchEpisodes,
-} from '../controllers/episodeController';
-import { authenticate } from '../middleware/auth';
-
-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);
-
-// Public routes
-router.get('/:episodeId', getEpisode);
-router.get('/podcast/:podcastId', getPodcastEpisodes);
-
-export default router;
diff --git a/backend/src/routes/podcastRoutes.ts b/backend/src/routes/podcastRoutes.ts
deleted file mode 100644
index a03dc90..0000000
--- a/backend/src/routes/podcastRoutes.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { Router } from 'express';
-import {
- getUserSubscriptions,
- subscribe,
- unsubscribe,
- syncPodcast,
- searchPodcasts,
- getPodcast,
- discoverPodcasts,
- subscribeFromDiscovery,
- getTrendingPodcasts,
-} from '../controllers/podcastController';
-import { authenticate } from '../middleware/auth';
-
-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);
-router.post('/:podcastId/sync', authenticate, syncPodcast);
-
-// Public/discovery routes
-router.get('/discover', discoverPodcasts);
-router.get('/trending', getTrendingPodcasts);
-router.get('/search', searchPodcasts);
-router.get('/:podcastId', getPodcast);
-
-export default router;
diff --git a/backend/src/services/podcastIndexService.ts b/backend/src/services/podcastIndexService.ts
deleted file mode 100644
index 91b0fa7..0000000
--- a/backend/src/services/podcastIndexService.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import axios from 'axios';
-
-/**
- * iTunes Search API Service - Free, no API key required
- * https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/
- */
-
-interface ItunesSearchResult {
- collectionId: number;
- collectionName: string;
- feedUrl?: string;
- artworkUrl600?: string;
- artworkUrl100?: string;
- artistName?: string;
- primaryGenreName?: string;
- trackCount?: number;
- country?: string;
- description?: string;
-}
-
-interface ItunesSearchResponse {
- resultCount: number;
- results: ItunesSearchResult[];
-}
-
-interface ItunesTopResult {
- id: string;
- name: string;
- artworkUrl100: string;
- artistName: string;
-}
-
-export interface SearchResult {
- id: string;
- title: string;
- rssUrl: string;
- description: string;
- author?: string;
- imageUrl?: string;
- language?: string;
- episodeCount?: number;
-}
-
-const mapItunesResult = (item: ItunesSearchResult): SearchResult | null => {
- if (!item.feedUrl) return null;
- return {
- id: item.collectionId.toString(),
- title: item.collectionName,
- rssUrl: item.feedUrl,
- description: item.description || item.primaryGenreName || '',
- author: item.artistName || '',
- imageUrl: item.artworkUrl600 || item.artworkUrl100,
- episodeCount: item.trackCount,
- };
-};
-
-export const podcastIndexService = {
- searchPodcasts: async (
- query: string,
- limit: number = 20,
- ): Promise => {
- const term = query.trim();
- if (!term) return [];
-
- const response = await axios.get(
- 'https://itunes.apple.com/search',
- {
- params: {
- term,
- media: 'podcast',
- entity: 'podcast',
- country: 'FR',
- limit: 100, // Fetch more candidates to improve quality
- },
- timeout: 10000,
- }
- );
-
- return response.data.results
- .map(mapItunesResult)
- .filter((r): r is SearchResult => r !== null)
- .slice(0, limit); // Respect requested limit after fetching candidates
- },
-
- getTrendingPodcasts: async (limit: number = 20, genreId?: string): Promise => {
- // iTunes official top podcasts RSS
- // Format: https://itunes.apple.com/fr/rss/toppodcasts/limit=X/genre=Y/json
- const cap = Math.min(limit, 50);
- const genrePath = genreId ? `/genre=${genreId}` : '';
- const rssResponse = await axios.get<{
- feed: {
- entry: Array<{
- id: { label: string; attributes: { 'im:id': string } };
- 'im:name': { label: string };
- 'im:artist': { label: string };
- 'im:image': Array<{ label: string; attributes: { height: string } }>;
- }>;
- };
- }>(`https://itunes.apple.com/fr/rss/toppodcasts/limit=${cap}${genrePath}/json`, {
- timeout: 10000,
- });
-
- const entries = rssResponse.data.feed.entry;
- if (!entries || entries.length === 0) return [];
-
- // Batch lookup to get feedUrls
- const ids = entries.map((e) => e.id.attributes['im:id']).join(',');
- const lookupResponse = await axios.get(
- 'https://itunes.apple.com/lookup',
- { params: { id: ids, entity: 'podcast' }, timeout: 10000 }
- );
-
- return lookupResponse.data.results
- .map(mapItunesResult)
- .filter((r): r is SearchResult => r !== null);
- },
-
- isConfigured: (): boolean => true,
-};
diff --git a/backend/src/services/rssParserService.ts b/backend/src/services/rssParserService.ts
deleted file mode 100644
index 3fb1460..0000000
--- a/backend/src/services/rssParserService.ts
+++ /dev/null
@@ -1,368 +0,0 @@
-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: 30000, // Increased to 30 seconds for complex feeds
- headers: {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
- 'Accept': 'application/rss+xml, application/xml, text/xml, */*'
- },
- customFields: {
- item: [
- ['media:content', 'mediaContent'],
- ['itunes:summary', 'itunesSummary'],
- ['itunes:subtitle', 'itunesSubtitle'],
- ['itunes:duration', 'itunesDuration'],
- ['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');
- }
-
- // Robust image extraction
- let imageUrl = feed.image?.url;
- if (!imageUrl && feed.itunes?.image) {
- imageUrl = typeof feed.itunes.image === 'string' ? feed.itunes.image : (feed.itunes.image as any).href;
- }
-
- // Robust author extraction
- const author = feed.author || feed.creator || feed.itunes?.author || (feed as any).managingEditor || '';
-
- // Robust description extraction
- const description = feed.description || feed.itunes?.summary || feed.itunes?.subtitle || (feed as any).content || '';
-
- console.log(`[RSS Parser] Meta Extracted for "${feed.title}": Author: ${author}, Image: ${!!imageUrl}`);
-
- return {
- title: feed.title,
- description: description,
- author: author,
- imageUrl: imageUrl,
- language: feed.language?.split('-')[0].toLowerCase() || '',
- 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 finding mp3 in link or guid
- const mp3Regex = /\.mp3($|\?)/i;
- if (item.link && mp3Regex.test(item.link)) {
- return item.link;
- }
- if (item.guid && mp3Regex.test(item.guid)) {
- return item.guid;
- }
-
- // Try finding mp3 in content:encoded
- 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 typeof item.itunes.image === 'string' ? item.itunes.image : (item.itunes.image as any).href;
- }
-
- // 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, rssUrl: string = ''): ParsedEpisode[] => {
- const parsed = items
- .filter((item) => {
- const audioUrl = rssParserService.extractAudioUrl(item);
- const hasTitle = !!item.title;
-
- if (!audioUrl || !hasTitle) {
- // Log only a sample to avoid flooding, but enough to debug
- if (items.indexOf(item) === 0) {
- console.log(`[RSS Debug] Item check failed for "${item.title || 'Untitled'}": Audio: ${!!audioUrl}, RSS URL Sample: ${rssUrl.substring(0, 30)}...`);
- if (!audioUrl) console.log(`[RSS Debug] Available keys for item: ${Object.keys(item).join(', ')}`);
- }
- }
-
- return !!audioUrl && hasTitle;
- });
-
- return parsed.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, force: boolean = false) => {
- try {
- const redis = getRedis();
- const cacheKey = `podcast:${podcastId}:sync`;
-
- // Check cache unless forced
- if (!force) {
- const cached = await redis.get(cacheKey);
- if (cached) {
- console.log(`[RSS Sync] Returning cached result for ${podcastId}`);
- return JSON.parse(cached);
- }
- } else {
- console.log(`[RSS Sync] Force sync requested for ${podcastId}`);
- }
-
- 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 });
-
- // Refresh podcast metadata from fresh RSS data (fixes "Unknown Podcast" legacy records)
- if (feedData.title && feedData.title !== 'Unknown Podcast') {
- podcast.title = feedData.title;
- }
- if (feedData.author && (!podcast.author || podcast.author.trim() === '' || force)) {
- podcast.author = feedData.author;
- }
- if (feedData.imageUrl && (!podcast.imageUrl || force)) {
- podcast.imageUrl = feedData.imageUrl;
- }
- if (feedData.description && (!podcast.description || force)) {
- podcast.description = feedData.description;
- }
- if (feedData.language && (!podcast.language || force)) {
- podcast.language = feedData.language;
- }
-
- if (episodes.length > 0) {
- podcast.lastEpisodeTitle = episodes[0].title;
- podcast.lastEpisodeDate = episodes[0].pubDate;
- }
-
- 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');
- }
-
- // Always parse feed to get fresh description and episodes, even if partial data provided
- if (!feedData || !feedData.description || !feedData.episodes || feedData.episodes.length === 0) {
- const freshData = await rssParserService.parseFeed(rssUrl);
- feedData = { ...feedData, ...freshData };
- }
-
- // 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, rssUrl);
-
- 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();
-
- if (episodes.length > 0) {
- console.log(`[RSS Parser] Setting last episode for ${podcast.title}: ${episodes[0].title} (${episodes[0].pubDate})`);
- podcast.lastEpisodeTitle = episodes[0].title;
- podcast.lastEpisodeDate = episodes[0].pubDate;
- } else {
- console.warn(`[RSS Parser] No episodes found for ${podcast.title} during creation.`);
- }
-
- await podcast.save();
-
- return {
- podcast: podcast.toJSON(),
- episodes: podcast.episodeCount,
- };
- } catch (error: any) {
- console.error('Create podcast error:', error.message);
- throw error;
- }
- },
-};
diff --git a/backend/src/services/syncService.ts b/backend/src/services/syncService.ts
deleted file mode 100644
index eddefdb..0000000
--- a/backend/src/services/syncService.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-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);
- }
- }
-};
diff --git a/backend/src/utils/jwt.ts b/backend/src/utils/jwt.ts
deleted file mode 100644
index 551da13..0000000
--- a/backend/src/utils/jwt.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import jwt from 'jsonwebtoken';
-import { IUser } from '../models/User';
-
-import type { SignOptions } from 'jsonwebtoken';
-
-const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_key';
-const JWT_EXPIRES_IN = (process.env.JWT_EXPIRES_IN || '7d') as SignOptions['expiresIn'];
-const JWT_REFRESH_EXPIRES_IN = (process.env.JWT_REFRESH_EXPIRES_IN || '30d') as SignOptions['expiresIn'];
-
-export interface JwtPayload {
- id: string;
- email: string;
-}
-
-export interface TokenPair {
- accessToken: string;
- refreshToken: string;
-}
-
-export const generateTokens = (user: IUser): TokenPair => {
- const payload: JwtPayload = {
- id: user._id.toString(),
- email: user.email,
- };
-
- const accessToken = jwt.sign(payload, JWT_SECRET, {
- expiresIn: JWT_EXPIRES_IN,
- });
-
- const refreshToken = jwt.sign(payload, JWT_SECRET, {
- expiresIn: JWT_REFRESH_EXPIRES_IN,
- });
-
- return { accessToken, refreshToken };
-};
-
-export const verifyAccessToken = (token: string): JwtPayload => {
- try {
- return jwt.verify(token, JWT_SECRET) as JwtPayload;
- } catch (error) {
- throw new Error('Invalid or expired token');
- }
-};
-
-export const verifyRefreshToken = (token: string): JwtPayload => {
- try {
- return jwt.verify(token, JWT_SECRET) as JwtPayload;
- } catch (error) {
- throw new Error('Invalid or expired refresh token');
- }
-};
-
-export const decodeToken = (token: string): JwtPayload | null => {
- try {
- return jwt.decode(token) as JwtPayload | null;
- } catch {
- return null;
- }
-};
diff --git a/backend/tsconfig.json b/backend/tsconfig.json
deleted file mode 100644
index c270af5..0000000
--- a/backend/tsconfig.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "compilerOptions": {
- "target": "ES2020",
- "module": "CommonJS",
- "lib": ["ES2020"],
- "moduleResolution": "node",
- "resolveJsonModule": true,
- "allowJs": true,
- "strict": true,
- "esModuleInterop": true,
- "skipLibCheck": true,
- "forceConsistentCasingInFileNames": true,
- "outDir": "./dist",
- "rootDir": "./src",
- "declaration": true,
- "declarationMap": true,
- "sourceMap": true
- },
- "include": ["src/**/*"],
- "exclude": ["node_modules", "dist"]
-}
diff --git a/dev.bat b/dev.bat
index 40339fa..f747e30 100644
--- a/dev.bat
+++ b/dev.bat
@@ -21,9 +21,9 @@ echo ⚠️ Make sure MongoDB and Redis are running!
echo.
REM Start backend
-echo 🚀 Starting Backend (Express.js)...
+echo 🚀 Starting Backend (Rust)...
cd backend
-start "Podcastic Backend" npm run dev
+start "Podcastic Backend" cmd /c "set PORT=5000&& cargo run"
cd ..
timeout /t 2 /nobreak
diff --git a/dev.sh b/dev.sh
index f1fa7b5..f7e38f8 100644
--- a/dev.sh
+++ b/dev.sh
@@ -42,7 +42,7 @@ echo ""
# Start backend in background
echo "→ Backend starting on port 5000..."
cd backend
-npm run dev &
+PORT=5000 PUBLIC_DIR=../frontend/dist cargo run &
BACKEND_PID=$!
cd ..
diff --git a/docker-compose.yml b/docker-compose.yml
index 2aa5f52..e14d99a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -30,12 +30,14 @@ services:
app:
image: ghcr.io/r0m1k3/podcastic:latest
+ # Always fetch the newest image when the stack (re)starts
+ pull_policy: always
container_name: podcastic-app
restart: unless-stopped
ports:
- "${PORT:-3579}:3579"
environment:
- NODE_ENV: production
+ RUST_LOG: podcastic=info,tower_http=warn
PORT: 3579
MONGODB_URI: mongodb://mongodb:27017/podcastic
REDIS_URL: redis://redis:6379