From 1028b7968c8de512722cd8f49357db642127f928 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 13 Oct 2026 09:31:52 +0200 Subject: [PATCH] feat: rewrite backend in Rust (axum) Replace the Node/Express backend with a Rust implementation that keeps the same REST API, MongoDB collections and Redis cache keys, so existing users, subscriptions and progress keep working. - axum + mongodb + redis, bcrypt and HS256 JWT compatible with old data - RSS parsing, iTunes discovery/trending, background sync (1h / 4h) - Docker image now builds a static Rust binary on debian-slim - CI runs cargo tests before publishing the image - compose uses pull_policy: always so a restart picks up the new image Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docker-publish.yml | 25 +- .gitignore | 3 + Dockerfile | 40 +- README.md | 15 +- UNRAID_INSTALL.md | 8 +- backend/Cargo.toml | 31 + backend/eslint.config.mjs | 16 - backend/package.json | 45 -- backend/src/auth.rs | 303 ++++++++ backend/src/config/database.ts | 28 - backend/src/config/redis.ts | 46 -- backend/src/controllers/authController.ts | 121 ---- backend/src/controllers/episodeController.ts | 224 ------ backend/src/controllers/podcastController.ts | 328 --------- backend/src/db.rs | 172 +++++ backend/src/episodes.rs | 237 ++++++ backend/src/error.rs | 66 ++ backend/src/feeds.rs | 722 +++++++++++++++++++ backend/src/index.ts | 124 ---- backend/src/itunes.rs | 139 ++++ backend/src/main.rs | 237 ++++++ backend/src/middleware/auth.ts | 54 -- backend/src/middleware/errorHandler.ts | 31 - backend/src/models/Episode.ts | 65 -- backend/src/models/Podcast.ts | 79 -- backend/src/models/User.ts | 89 --- backend/src/models/UserProgress.ts | 57 -- backend/src/models/UserSubscription.ts | 47 -- backend/src/podcasts.rs | 311 ++++++++ backend/src/routes/audioRoutes.ts | 141 ---- backend/src/routes/authRoutes.ts | 15 - backend/src/routes/episodeRoutes.ts | 24 - backend/src/routes/podcastRoutes.ts | 30 - backend/src/services/podcastIndexService.ts | 119 --- backend/src/services/rssParserService.ts | 368 ---------- backend/src/services/syncService.ts | 69 -- backend/src/utils/jwt.ts | 59 -- backend/tsconfig.json | 21 - dev.bat | 4 +- dev.sh | 2 +- docker-compose.yml | 4 +- 41 files changed, 2280 insertions(+), 2239 deletions(-) create mode 100644 backend/Cargo.toml delete mode 100644 backend/eslint.config.mjs delete mode 100644 backend/package.json create mode 100644 backend/src/auth.rs delete mode 100644 backend/src/config/database.ts delete mode 100644 backend/src/config/redis.ts delete mode 100644 backend/src/controllers/authController.ts delete mode 100644 backend/src/controllers/episodeController.ts delete mode 100644 backend/src/controllers/podcastController.ts create mode 100644 backend/src/db.rs create mode 100644 backend/src/episodes.rs create mode 100644 backend/src/error.rs create mode 100644 backend/src/feeds.rs delete mode 100644 backend/src/index.ts create mode 100644 backend/src/itunes.rs create mode 100644 backend/src/main.rs delete mode 100644 backend/src/middleware/auth.ts delete mode 100644 backend/src/middleware/errorHandler.ts delete mode 100644 backend/src/models/Episode.ts delete mode 100644 backend/src/models/Podcast.ts delete mode 100644 backend/src/models/User.ts delete mode 100644 backend/src/models/UserProgress.ts delete mode 100644 backend/src/models/UserSubscription.ts create mode 100644 backend/src/podcasts.rs delete mode 100644 backend/src/routes/audioRoutes.ts delete mode 100644 backend/src/routes/authRoutes.ts delete mode 100644 backend/src/routes/episodeRoutes.ts delete mode 100644 backend/src/routes/podcastRoutes.ts delete mode 100644 backend/src/services/podcastIndexService.ts delete mode 100644 backend/src/services/rssParserService.ts delete mode 100644 backend/src/services/syncService.ts delete mode 100644 backend/src/utils/jwt.ts delete mode 100644 backend/tsconfig.json 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>, +) -> mongodb::error::Result> { + collection.find(filter, options).await?.try_collect().await +} + +pub fn is_duplicate_key(err: &MongoError) -> bool { + matches!( + err.kind.as_ref(), + ErrorKind::Write(WriteFailure::WriteError(write_error)) if write_error.code == 11000 + ) +} + +/// Converts BSON to the JSON shape Mongoose used to emit +/// (ObjectIds as hex strings, dates as ISO-8601 strings). +pub fn to_json(value: Bson) -> Value { + match value { + Bson::ObjectId(id) => Value::String(id.to_hex()), + Bson::DateTime(dt) => Value::String(iso(dt)), + Bson::Document(document) => doc_to_json(document), + Bson::Array(items) => Value::Array(items.into_iter().map(to_json).collect()), + Bson::String(s) => Value::String(s), + Bson::Boolean(b) => Value::Bool(b), + Bson::Int32(i) => Value::from(i), + Bson::Int64(i) => Value::from(i), + Bson::Double(f) if f.fract() == 0.0 && f.abs() < 9e15 => Value::from(f as i64), + Bson::Double(f) => serde_json::Number::from_f64(f) + .map(Value::Number) + .unwrap_or(Value::Null), + Bson::Null | Bson::Undefined => Value::Null, + other => other.into_relaxed_extjson(), + } +} + +pub fn doc_to_json(document: Document) -> Value { + Value::Object( + document + .into_iter() + .map(|(key, value)| (key, to_json(value))) + .collect::>(), + ) +} + +/// Mongoose-style `populate('podcastId', fields)`: replaces each episode's +/// `podcastId` with the selected podcast fields, or null when missing. +pub async fn populate_podcasts( + db: &Database, + episodes: &mut [Document], + fields: &[&str], +) -> mongodb::error::Result<()> { + let ids: Vec = episodes + .iter() + .filter_map(|e| e.get_object_id("podcastId").ok()) + .collect(); + if ids.is_empty() { + return Ok(()); + } + + let mut projection = Document::new(); + for field in fields { + projection.insert(*field, 1); + } + let options = FindOptions::builder().projection(projection).build(); + let podcasts: HashMap = + find_all(&coll(db, PODCASTS), doc! { "_id": { "$in": ids } }, options) + .await? + .into_iter() + .filter_map(|p| Some((p.get_object_id("_id").ok()?, p))) + .collect(); + + for episode in episodes.iter_mut() { + let populated = episode + .get_object_id("podcastId") + .ok() + .and_then(|id| podcasts.get(&id).cloned()) + .map(Bson::Document) + .unwrap_or(Bson::Null); + episode.insert("podcastId", populated); + } + Ok(()) +} diff --git a/backend/src/episodes.rs b/backend/src/episodes.rs new file mode 100644 index 0000000..881a399 --- /dev/null +++ b/backend/src/episodes.rs @@ -0,0 +1,237 @@ +use std::collections::HashMap; + +use axum::{ + extract::{Path, Query, State}, + Json, +}; +use bson::{doc, oid::ObjectId, Bson, Document}; +use mongodb::options::{FindOneAndUpdateOptions, FindOptions, ReturnDocument}; +use serde_json::{json, Value}; + +use crate::{ + auth::{AuthUser, MaybeUser}, + db::{self, coll, find_all, populate_podcasts, EPISODES, PROGRESS, SUBSCRIPTIONS}, + error::{internal, ApiResult, AppError}, + podcasts::num_param, + AppState, +}; + +type Params = Query>; + +const LIST_FIELDS: &[&str] = &["title", "author", "imageUrl"]; + +/// Serializes episodes, adding `progress: { position, isCompleted } | null` for `user`. +async fn with_progress( + state: &AppState, + user: Option<&AuthUser>, + episodes: Vec, +) -> mongodb::error::Result> { + let Some(user) = user else { + return Ok(episodes.into_iter().map(db::doc_to_json).collect()); + }; + + let ids: Vec = episodes + .iter() + .filter_map(|e| e.get_object_id("_id").ok()) + .collect(); + let progress: HashMap = find_all( + &coll(&state.db, PROGRESS), + doc! { "userId": user.id, "episodeId": { "$in": ids } }, + None, + ) + .await? + .into_iter() + .filter_map(|p| { + let id = p.get_object_id("episodeId").ok()?; + let position = p.get("position").cloned().map(db::to_json).unwrap_or(json!(0)); + let completed = p.get_bool("isCompleted").unwrap_or(false); + Some((id, json!({ "position": position, "isCompleted": completed }))) + }) + .collect(); + + Ok(episodes + .into_iter() + .map(|episode| { + let entry = episode + .get_object_id("_id") + .ok() + .and_then(|id| progress.get(&id).cloned()) + .unwrap_or(Value::Null); + let mut value = db::doc_to_json(episode); + value["progress"] = entry; + value + }) + .collect()) +} + +fn list_options(params: &HashMap, default_limit: i64, sort: Document) -> FindOptions { + FindOptions::builder() + .sort(sort) + .skip(num_param(params, "skip", 0) as u64) + .limit(num_param(params, "limit", default_limit)) + .build() +} + +pub async fn latest( + State(state): State, + user: AuthUser, + Query(params): Params, +) -> ApiResult { + const ERR: &str = "Failed to fetch episodes"; + let podcast_ids: Vec = find_all(&coll(&state.db, SUBSCRIPTIONS), doc! { "userId": user.id }, None) + .await + .map_err(internal(ERR))? + .into_iter() + .filter_map(|s| s.get("podcastId").cloned()) + .collect(); + if podcast_ids.is_empty() { + return Ok(Json(json!({ "episodes": [], "count": 0 }))); + } + + let options = list_options(¶ms, 30, doc! { "pubDate": -1 }); + let mut episodes = find_all( + &coll(&state.db, EPISODES), + doc! { "podcastId": { "$in": podcast_ids } }, + options, + ) + .await + .map_err(internal(ERR))?; + populate_podcasts(&state.db, &mut episodes, LIST_FIELDS) + .await + .map_err(internal(ERR))?; + let episodes = with_progress(&state, Some(&user), episodes) + .await + .map_err(internal(ERR))?; + + Ok(Json(json!({ "count": episodes.len(), "episodes": episodes }))) +} + +pub async fn for_podcast( + State(state): State, + MaybeUser(user): MaybeUser, + Path(podcast_id): Path, + Query(params): Params, +) -> ApiResult { + const ERR: &str = "Failed to fetch episodes"; + let Some(podcast_id) = db::oid(&podcast_id) else { + return Ok(Json(json!({ "episodes": [], "count": 0 }))); + }; + let options = list_options(¶ms, 50, doc! { "pubDate": -1 }); + let episodes = find_all(&coll(&state.db, EPISODES), doc! { "podcastId": podcast_id }, options) + .await + .map_err(internal(ERR))?; + let episodes = with_progress(&state, user.as_ref(), episodes) + .await + .map_err(internal(ERR))?; + + Ok(Json(json!({ "count": episodes.len(), "episodes": episodes }))) +} + +pub async fn get_one(State(state): State, Path(id): Path) -> ApiResult { + const ERR: &str = "Failed to fetch episode"; + let not_found = || AppError::not_found("Episode not found"); + let id = db::oid(&id).ok_or_else(not_found)?; + let episode = coll(&state.db, EPISODES) + .find_one(doc! { "_id": id }, None) + .await + .map_err(internal(ERR))? + .ok_or_else(not_found)?; + + let mut episodes = vec![episode]; + populate_podcasts(&state.db, &mut episodes, &["title", "author", "description", "imageUrl"]) + .await + .map_err(internal(ERR))?; + let episode = episodes.pop().map(db::doc_to_json); + Ok(Json(json!({ "episode": episode }))) +} + +pub async fn save_progress( + State(state): State, + user: AuthUser, + Json(body): Json, +) -> ApiResult { + let episode_id = body.get("episodeId").and_then(Value::as_str).unwrap_or_default(); + let position = body.get("position").and_then(Value::as_f64); + let (Some(position), false) = (position, episode_id.is_empty()) else { + return Err(AppError::bad_request("Episode ID and position required")); + }; + let episode_id = db::oid(episode_id).ok_or_else(|| AppError::bad_request("Invalid episode ID"))?; + let is_completed = body.get("isCompleted").and_then(Value::as_bool); + + let now = db::now(); + let mut set = doc! { "position": position.max(0.0), "lastListenedAt": now, "updatedAt": now }; + let mut on_insert = doc! { "isSaved": false, "createdAt": now, "__v": 0 }; + match is_completed { + Some(completed) => set.insert("isCompleted", completed), + None => on_insert.insert("isCompleted", false), + }; + + let options = FindOneAndUpdateOptions::builder() + .upsert(true) + .return_document(ReturnDocument::After) + .build(); + let progress = coll(&state.db, PROGRESS) + .find_one_and_update( + doc! { "userId": user.id, "episodeId": episode_id }, + doc! { "$set": set, "$setOnInsert": on_insert }, + options, + ) + .await + .map_err(internal("Failed to save progress"))?; + + Ok(Json(json!({ + "message": "Progress saved", + "progress": progress.map(db::doc_to_json), + }))) +} + +pub async fn get_progress( + State(state): State, + user: AuthUser, + Path(episode_id): Path, +) -> ApiResult { + let none = || Json(json!({ "progress": null, "message": "No progress found" })); + let Some(episode_id) = db::oid(&episode_id) else { + return Ok(none()); + }; + let progress = coll(&state.db, PROGRESS) + .find_one(doc! { "userId": user.id, "episodeId": episode_id }, None) + .await + .map_err(internal("Failed to fetch progress"))?; + + Ok(match progress { + Some(progress) => Json(json!({ "progress": db::doc_to_json(progress) })), + None => none(), + }) +} + +pub async fn search( + State(state): State, + user: AuthUser, + Query(params): Params, +) -> ApiResult { + const ERR: &str = "Search failed"; + 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 mut options = list_options(¶ms, 40, score.clone()); + options.projection = Some(score); + let mut episodes = find_all( + &coll(&state.db, EPISODES), + doc! { "$text": { "$search": query } }, + options, + ) + .await + .map_err(internal(ERR))?; + populate_podcasts(&state.db, &mut episodes, LIST_FIELDS) + .await + .map_err(internal(ERR))?; + let episodes = with_progress(&state, Some(&user), episodes) + .await + .map_err(internal(ERR))?; + + Ok(Json(json!({ "count": episodes.len(), "episodes": episodes }))) +} diff --git a/backend/src/error.rs b/backend/src/error.rs new file mode 100644 index 0000000..2515852 --- /dev/null +++ b/backend/src/error.rs @@ -0,0 +1,66 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::{json, Value}; + +/// API error rendered as a JSON body (`{ "message": ... }` by default). +#[derive(Debug)] +pub struct AppError { + pub status: StatusCode, + pub body: Value, +} + +impl AppError { + pub fn new(status: StatusCode, message: impl Into) -> Self { + Self { + status, + body: json!({ "message": message.into() }), + } + } + + pub fn with_body(status: StatusCode, body: Value) -> Self { + Self { status, body } + } + + pub fn bad_request(message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, message) + } + + pub fn unauthorized(message: impl Into) -> Self { + Self::new(StatusCode::UNAUTHORIZED, message) + } + + pub fn not_found(message: impl Into) -> Self { + Self::new(StatusCode::NOT_FOUND, message) + } + + pub fn validation(errors: Vec) -> Self { + Self::with_body( + StatusCode::BAD_REQUEST, + json!({ "message": "Validation error", "errors": errors }), + ) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + (self.status, Json(self.body)).into_response() + } +} + +pub type ApiResult> = Result; + +/// Logs the underlying error and maps it to a 500 carrying `message`. +pub fn internal(message: &'static str) -> impl FnOnce(E) -> AppError { + move |err| { + tracing::error!("{message}: {err}"); + AppError::new(StatusCode::INTERNAL_SERVER_ERROR, message) + } +} + +/// Builds a zod-like validation issue. +pub fn issue(field: &str, message: &str) -> Value { + json!({ "path": [field], "message": message }) +} diff --git a/backend/src/feeds.rs b/backend/src/feeds.rs new file mode 100644 index 0000000..2b277a9 --- /dev/null +++ b/backend/src/feeds.rs @@ -0,0 +1,722 @@ +//! RSS feed fetching, parsing and episode synchronisation. + +use std::{collections::HashSet, time::Duration}; + +use anyhow::{anyhow, bail, Context}; +use bson::{doc, oid::ObjectId, Bson, Document}; +use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc}; +use mongodb::{ + error::{ErrorKind, Error as MongoError}, + options::{FindOptions, InsertManyOptions}, + Collection, +}; +use redis::AsyncCommands; +use reqwest::header::ACCEPT; +use serde_json::{json, Value}; + +use crate::{ + db::{self, coll, EPISODES, PODCASTS}, + AppState, +}; + +const SYNC_CACHE_TTL_SECS: u64 = 6 * 60 * 60; + +#[derive(Debug)] +pub struct FeedData { + pub title: String, + pub description: String, + pub author: String, + pub image_url: Option, + pub language: String, + pub episodes: Vec, +} + +#[derive(Debug)] +pub struct ParsedEpisode { + pub title: String, + pub description: String, + pub audio_url: String, + pub pub_date: DateTime, + pub duration: i64, + pub guid: String, + pub image_url: Option, +} + +pub async fn fetch_feed(http: &reqwest::Client, url: &str) -> anyhow::Result { + fetch_and_parse(http, url).await.map_err(|err| { + tracing::warn!("RSS parsing error for {url}: {err}"); + anyhow!("Failed to parse RSS feed: {err}") + }) +} + +async fn fetch_and_parse(http: &reqwest::Client, url: &str) -> anyhow::Result { + let response = http + .get(url) + .header(ACCEPT, "application/rss+xml, application/xml, text/xml, */*") + .timeout(Duration::from_secs(30)) + .send() + .await?; + if !response.status().is_success() { + bail!("Status code {}", response.status().as_u16()); + } + let bytes = response.bytes().await?; + let channel = rss::Channel::read_from(&bytes[..])?; + parse_channel(&channel) +} + +fn non_empty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|s| !s.is_empty()) +} + +pub fn parse_channel(channel: &rss::Channel) -> anyhow::Result { + let title = channel.title().trim(); + if title.is_empty() { + bail!("Invalid RSS feed - missing title"); + } + let itunes = channel.itunes_ext(); + + let image_url = non_empty(channel.image().map(|i| i.url())) + .or_else(|| non_empty(itunes.and_then(|i| i.image()))) + .map(str::to_owned); + + let author = non_empty(itunes.and_then(|i| i.author())) + .or_else(|| { + non_empty( + channel + .dublin_core_ext() + .and_then(|dc| dc.creators().first().map(String::as_str)), + ) + }) + .or_else(|| non_empty(channel.managing_editor())) + .unwrap_or_default() + .to_owned(); + + let description = non_empty(Some(channel.description())) + .or_else(|| non_empty(itunes.and_then(|i| i.summary()))) + .or_else(|| non_empty(itunes.and_then(|i| i.subtitle()))) + .unwrap_or_default() + .to_owned(); + + let language = channel + .language() + .and_then(|l| l.split('-').next()) + .map(|l| l.trim().to_lowercase()) + .unwrap_or_default(); + + let mut episodes: Vec = channel + .items() + .iter() + .filter_map(|item| parse_item(item, image_url.as_deref())) + .collect(); + episodes.sort_by(|a, b| b.pub_date.cmp(&a.pub_date)); + + tracing::info!( + "[RSS Parser] \"{title}\": author={author:?}, image={}, episodes={}", + image_url.is_some(), + episodes.len() + ); + + Ok(FeedData { + title: title.to_owned(), + description, + author, + image_url, + language, + episodes, + }) +} + +fn media_attr<'a>(item: &'a rss::Item, name: &str) -> Option<&'a str> { + item.extensions() + .get("media") + .and_then(|media| media.get("content")) + .and_then(|contents| contents.first()) + .and_then(|ext| ext.attrs().get(name)) + .map(String::as_str) +} + +fn is_mp3(url: &str) -> bool { + let lower = url.to_ascii_lowercase(); + lower.ends_with(".mp3") || lower.contains(".mp3?") +} + +/// First `href="...mp3..."` found in an HTML fragment. +fn mp3_href(html: &str) -> Option { + let mut rest = html; + while let Some(pos) = rest.find("href=") { + rest = &rest[pos + 5..]; + let Some(quote) = rest.chars().next().filter(|c| *c == '"' || *c == '\'') else { + continue; + }; + rest = &rest[1..]; + let end = rest.find(quote)?; + let candidate = &rest[..end]; + if candidate.to_ascii_lowercase().contains(".mp3") { + return Some(candidate.to_owned()); + } + rest = &rest[end..]; + } + None +} + +fn extract_audio_url(item: &rss::Item) -> Option { + if let Some(url) = non_empty(item.enclosure().map(|e| e.url())) { + return Some(url.to_owned()); + } + if let Some(url) = non_empty(media_attr(item, "url")) { + return Some(url.to_owned()); + } + if let Some(link) = non_empty(item.link()).filter(|l| is_mp3(l)) { + return Some(link.to_owned()); + } + if let Some(guid) = non_empty(item.guid().map(|g| g.value())).filter(|g| is_mp3(g)) { + return Some(guid.to_owned()); + } + item.content().and_then(mp3_href) +} + +/// Parses "HH:MM:SS", "MM:SS" or a plain number of seconds. +fn parse_duration(raw: &str) -> i64 { + let parts: Option> = raw + .trim() + .split(':') + .map(|p| p.trim().parse::().ok()) + .collect(); + let seconds = match parts.as_deref() { + Some([h, m, s]) => h * 3600.0 + m * 60.0 + s, + Some([m, s]) => m * 60.0 + s, + Some([s]) => *s, + _ => 0.0, + }; + if seconds.is_finite() && seconds > 0.0 { + seconds as i64 + } else { + 0 + } +} + +fn extract_image_url(item: &rss::Item, podcast_image: Option<&str>) -> Option { + if let Some(image) = non_empty(item.itunes_ext().and_then(|i| i.image())) { + return Some(image.to_owned()); + } + if media_attr(item, "medium") == Some("image") { + if let Some(url) = non_empty(media_attr(item, "url")) { + return Some(url.to_owned()); + } + } + podcast_image.map(str::to_owned) +} + +/// Lenient date parsing: RFC 2822 (incl. common named time zones), RFC 3339, plain dates. +pub fn parse_date(raw: &str) -> Option> { + let raw = raw.trim(); + if let Ok(dt) = DateTime::parse_from_rfc2822(raw) { + return Some(dt.with_timezone(&Utc)); + } + if let Ok(dt) = DateTime::parse_from_rfc3339(raw) { + return Some(dt.with_timezone(&Utc)); + } + + const ZONES: &[(&str, &str)] = &[ + ("CEST", "+0200"), ("CET", "+0100"), ("BST", "+0100"), ("IST", "+0530"), + ("PDT", "-0700"), ("PST", "-0800"), ("MDT", "-0600"), ("MST", "-0700"), + ("CDT", "-0500"), ("CST", "-0600"), ("EDT", "-0400"), ("EST", "-0500"), + ("UTC", "+0000"), ("GMT", "+0000"), ("Z", "+0000"), + ]; + for (name, offset) in ZONES { + if let Some(prefix) = raw.strip_suffix(name) { + let candidate = format!("{} {offset}", prefix.trim_end()); + if let Ok(dt) = DateTime::parse_from_rfc2822(&candidate) { + return Some(dt.with_timezone(&Utc)); + } + } + } + + // Drop a leading weekday ("Mon, ") that may be wrong or localised. + let without_weekday = raw.split_once(", ").map(|(_, rest)| rest).unwrap_or(raw); + for format in ["%d %b %Y %H:%M:%S %z", "%d %B %Y %H:%M:%S %z", "%Y-%m-%dT%H:%M:%S%.f%z"] { + if let Ok(dt) = DateTime::parse_from_str(without_weekday, format) { + return Some(dt.with_timezone(&Utc)); + } + } + for format in ["%d %b %Y %H:%M:%S", "%d %b %Y %H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S"] { + if let Ok(dt) = NaiveDateTime::parse_from_str(without_weekday, format) { + return Some(dt.and_utc()); + } + } + for format in ["%Y-%m-%d", "%d %b %Y"] { + if let Ok(date) = NaiveDate::parse_from_str(without_weekday, format) { + return date.and_hms_opt(0, 0, 0).map(|dt| dt.and_utc()); + } + } + None +} + +fn decode_entities(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(pos) = rest.find('&') { + out.push_str(&rest[..pos]); + rest = &rest[pos..]; + let decoded = rest.find(';').filter(|end| *end <= 10).and_then(|end| { + let entity = &rest[1..end]; + let ch = match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + "nbsp" => Some('\u{a0}'), + "hellip" => Some('…'), + "rsquo" => Some('’'), + "lsquo" => Some('‘'), + "rdquo" => Some('”'), + "ldquo" => Some('“'), + "ndash" => Some('–'), + "mdash" => Some('—'), + "eacute" => Some('é'), + "egrave" => Some('è'), + "agrave" => Some('à'), + "ccedil" => Some('ç'), + _ => entity + .strip_prefix("#x") + .or_else(|| entity.strip_prefix("#X")) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .or_else(|| entity.strip_prefix('#').and_then(|d| d.parse().ok())) + .and_then(char::from_u32), + }?; + Some((ch, end)) + }); + match decoded { + Some((ch, end)) => { + out.push(ch); + rest = &rest[end + 1..]; + } + None => { + out.push('&'); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + out +} + +/// Plain-text snippet of an HTML fragment (mirrors rss-parser's `contentSnippet`). +pub fn html_to_text(html: &str) -> String { + const BLOCK_TAGS: &[&str] = &[ + "br", "p", "ul", "ol", "li", "blockquote", "section", "table", "tr", "div", "h1", "h2", + "h3", "h4", "h5", "h6", + ]; + let mut out = String::with_capacity(html.len()); + let mut rest = html; + while let Some(start) = rest.find('<') { + out.push_str(&rest[..start]); + let Some(end) = rest[start..].find('>') else { + rest = &rest[start..]; + break; + }; + let tag = rest[start + 1..start + end] + .trim_start_matches('/') + .split(|c: char| c.is_whitespace() || c == '/') + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + if BLOCK_TAGS.contains(&tag.as_str()) && !out.ends_with('\n') && !out.is_empty() { + out.push('\n'); + } + rest = &rest[start + end + 1..]; + } + out.push_str(rest); + decode_entities(&out).trim().to_owned() +} + +fn parse_item(item: &rss::Item, podcast_image: Option<&str>) -> Option { + let title = non_empty(item.title())?.to_owned(); + let audio_url = extract_audio_url(item)?; + let itunes = item.itunes_ext(); + + let description = non_empty(item.description()) + .or_else(|| non_empty(itunes.and_then(|i| i.summary()))) + .or_else(|| non_empty(item.content())) + .map(html_to_text) + .unwrap_or_default(); + + let raw_date = non_empty(item.pub_date()); + let pub_date = raw_date.and_then(parse_date).unwrap_or_else(Utc::now); + + let guid = non_empty(item.guid().map(|g| g.value())) + .or_else(|| non_empty(item.link())) + .map(str::to_owned) + .unwrap_or_else(|| format!("{title}-{}", raw_date.unwrap_or("undefined"))); + + Some(ParsedEpisode { + description, + audio_url, + pub_date, + duration: itunes.and_then(|i| i.duration()).map(parse_duration).unwrap_or(0), + guid, + image_url: extract_image_url(item, podcast_image), + title, + }) +} + +fn opt_bson(value: &Option) -> Bson { + value.clone().map(Bson::String).unwrap_or(Bson::Null) +} + +fn episode_doc(podcast_id: ObjectId, episode: &ParsedEpisode, now: bson::DateTime) -> Document { + doc! { + "podcastId": podcast_id, + "title": episode.title.as_str(), + "description": episode.description.as_str(), + "audioUrl": episode.audio_url.as_str(), + "pubDate": bson::DateTime::from_chrono(episode.pub_date), + "duration": episode.duration, + "guid": episode.guid.as_str(), + "imageUrl": opt_bson(&episode.image_url), + "createdAt": now, + "updatedAt": now, + "__v": 0, + } +} + +/// Inserts episodes, silently skipping ones already stored (duplicate guid). +async fn insert_episodes( + collection: &Collection, + docs: Vec, +) -> mongodb::error::Result { + if docs.is_empty() { + return Ok(0); + } + let total = docs.len() as u64; + let options = InsertManyOptions::builder().ordered(false).build(); + match collection.insert_many(docs, options).await { + Ok(result) => Ok(result.inserted_ids.len() as u64), + Err(err) => match duplicate_only_failures(&err) { + Some(skipped) => Ok(total.saturating_sub(skipped)), + None => Err(err), + }, + } +} + +fn duplicate_only_failures(err: &MongoError) -> Option { + match err.kind.as_ref() { + ErrorKind::BulkWrite(failure) if failure.write_concern_error.is_none() => { + let errors = failure.write_errors.as_ref()?; + errors + .iter() + .all(|e| e.code == 11000) + .then_some(errors.len() as u64) + } + _ => None, + } +} + +/// Creates a podcast (and its episodes) from an RSS feed. If another request +/// created it concurrently, the existing document is returned. +pub async fn create_podcast_from_rss(state: &AppState, rss_url: &str) -> anyhow::Result { + let feed = fetch_feed(&state.http, rss_url).await?; + let podcasts = coll(&state.db, PODCASTS); + let now = db::now(); + let latest = feed.episodes.first(); + let language = if feed.language.is_empty() { "en" } else { feed.language.as_str() }; + let last_title = latest.map(|e| e.title.clone()).unwrap_or_default(); + let last_date = latest + .map(|e| Bson::DateTime(bson::DateTime::from_chrono(e.pub_date))) + .unwrap_or(Bson::Null); + + let mut podcast = doc! { + "title": feed.title.as_str(), + "description": feed.description.as_str(), + "rssUrl": rss_url, + "imageUrl": opt_bson(&feed.image_url), + "author": feed.author.as_str(), + "category": [], + "language": language, + "lastFetched": now, + "episodeCount": 0, + "lastEpisodeTitle": last_title, + "lastEpisodeDate": last_date, + "createdAt": now, + "updatedAt": now, + "__v": 0, + }; + + let podcast_id = match podcasts.insert_one(&podcast, None).await { + Ok(result) => result + .inserted_id + .as_object_id() + .context("unexpected podcast id type")?, + Err(err) if db::is_duplicate_key(&err) => { + return podcasts + .find_one(doc! { "rssUrl": rss_url }, None) + .await? + .ok_or_else(|| anyhow!("Podcast not found")); + } + Err(err) => return Err(err.into()), + }; + + let episodes = coll(&state.db, EPISODES); + let docs = feed + .episodes + .iter() + .map(|e| episode_doc(podcast_id, e, now)) + .collect(); + insert_episodes(&episodes, docs).await?; + + let count = episodes + .count_documents(doc! { "podcastId": podcast_id }, None) + .await? as i64; + podcasts + .update_one( + doc! { "_id": podcast_id }, + doc! { "$set": { "episodeCount": count } }, + None, + ) + .await?; + + if feed.episodes.is_empty() { + tracing::warn!("[RSS Parser] No episodes found for {} during creation.", feed.title); + } + + podcast.insert("_id", podcast_id); + podcast.insert("episodeCount", count); + Ok(podcast) +} + +fn is_blank(podcast: &Document, key: &str) -> bool { + podcast.get_str(key).map(|s| s.trim().is_empty()).unwrap_or(true) +} + +/// Fetches the feed and stores new episodes. Results are cached in Redis for +/// six hours unless `force` is set. +pub async fn sync_podcast( + state: &AppState, + podcast_id: ObjectId, + rss_url: &str, + force: bool, +) -> anyhow::Result { + let cache_key = format!("podcast:{}:sync", podcast_id.to_hex()); + let mut redis = state.redis.clone(); + + if !force { + if let Ok(Some(cached)) = redis.get::<_, Option>(&cache_key).await { + if let Ok(value) = serde_json::from_str::(&cached) { + return Ok(value); + } + } + } + + let podcasts = coll(&state.db, PODCASTS); + let podcast = podcasts + .find_one(doc! { "_id": podcast_id }, None) + .await? + .ok_or_else(|| anyhow!("Podcast not found"))?; + + let feed = fetch_feed(&state.http, rss_url).await?; + let episodes = coll(&state.db, EPISODES); + let now = db::now(); + + let known: HashSet = episodes + .distinct("guid", doc! { "podcastId": podcast_id }, None) + .await? + .into_iter() + .filter_map(|guid| guid.as_str().map(str::to_owned)) + .collect(); + let new_docs: Vec = feed + .episodes + .iter() + .filter(|e| !known.contains(&e.guid)) + .map(|e| episode_doc(podcast_id, e, now)) + .collect(); + let new_count = insert_episodes(&episodes, new_docs).await?; + + let total = episodes + .count_documents(doc! { "podcastId": podcast_id }, None) + .await? as i64; + + let mut set = doc! { "lastFetched": now, "episodeCount": total, "updatedAt": now }; + if feed.title != "Unknown Podcast" { + set.insert("title", feed.title.as_str()); + } + if !feed.author.is_empty() && (force || is_blank(&podcast, "author")) { + set.insert("author", feed.author.as_str()); + } + if let Some(image) = &feed.image_url { + if force || is_blank(&podcast, "imageUrl") { + set.insert("imageUrl", image.as_str()); + } + } + if !feed.description.is_empty() && (force || is_blank(&podcast, "description")) { + set.insert("description", feed.description.as_str()); + } + if !feed.language.is_empty() && (force || is_blank(&podcast, "language")) { + set.insert("language", feed.language.as_str()); + } + if let Some(latest) = feed.episodes.first() { + set.insert("lastEpisodeTitle", latest.title.as_str()); + set.insert("lastEpisodeDate", bson::DateTime::from_chrono(latest.pub_date)); + } + podcasts + .update_one(doc! { "_id": podcast_id }, doc! { "$set": set }, None) + .await?; + + let result = json!({ + "success": true, + "newEpisodes": new_count, + "totalEpisodes": total, + "lastFetched": db::iso(now), + }); + if let Err(err) = redis + .set_ex::<_, _, ()>(&cache_key, result.to_string(), SYNC_CACHE_TTL_SECS) + .await + { + tracing::warn!("Redis cache write failed: {err}"); + } + Ok(result) +} + +async fn podcasts_matching(state: &AppState, filter: Document) -> Vec { + let options = FindOptions::builder() + .projection(doc! { "_id": 1, "rssUrl": 1, "title": 1 }) + .build(); + match db::find_all(&coll(&state.db, PODCASTS), filter, options).await { + Ok(podcasts) => podcasts, + Err(err) => { + tracing::error!("[Sync Engine] Failed to list podcasts: {err}"); + Vec::new() + } + } +} + +async fn sync_all(state: AppState) { + let podcasts = podcasts_matching(&state, doc! {}).await; + tracing::info!("[Sync Engine] Processing {} podcasts...", podcasts.len()); + for podcast in podcasts { + let (Ok(id), Ok(url)) = (podcast.get_object_id("_id"), podcast.get_str("rssUrl")) else { + continue; + }; + if let Err(err) = sync_podcast(&state, id, url, false).await { + let title = podcast.get_str("title").unwrap_or_default(); + tracing::error!("[Sync Engine] Failed to sync {title}: {err}"); + } + // Avoid hammering feed hosts. + tokio::time::sleep(Duration::from_secs(5)).await; + } + tracing::info!("[Sync Engine] Global sync completed"); +} + +async fn sync_empty(state: AppState) { + let podcasts = podcasts_matching(&state, doc! { "episodeCount": 0 }).await; + if podcasts.is_empty() { + return; + } + tracing::info!("[Sync Engine] Retrying {} empty podcasts...", podcasts.len()); + for podcast in podcasts { + let (Ok(id), Ok(url)) = (podcast.get_object_id("_id"), podcast.get_str("rssUrl")) else { + continue; + }; + if let Err(err) = sync_podcast(&state, id, url, true).await { + tracing::error!("[Sync Engine] Empty podcast sync failed: {err}"); + } + } +} + +/// Hourly: retry podcasts without episodes. Every 4 hours: sync the whole library. +pub fn start_background_sync(state: AppState) { + tracing::info!("✓ Background Sync Engine started"); + tokio::spawn(async move { + loop { + let now = Utc::now(); + let next = now.timestamp() - now.timestamp().rem_euclid(3600) + 3600; + let wait_ms = (next * 1000 - now.timestamp_millis()).max(1000) as u64; + tokio::time::sleep(Duration::from_millis(wait_ms)).await; + + if (next / 3600) % 4 == 0 { + tracing::info!("[Cron] Starting global library sync"); + tokio::spawn(sync_all(state.clone())); + } + tokio::spawn(sync_empty(state.clone())); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + const FEED: &str = r#" + + + Mon Podcast + Desc + fr-FR + Radio + + + Episode 1 + Hello & welcome

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