├── .dockerignore ├── .env.example ├── .github └── workflows │ └── fly-deploy.yml ├── .gitignore ├── .python-version ├── Dockerfile ├── README.md ├── app.py ├── bun.lockb ├── components.json ├── eslint.config.js ├── fly.toml ├── index.html ├── package.json ├── postcss.config.js ├── requirements.txt ├── runtime.txt ├── src ├── App.css ├── App.tsx ├── components │ ├── APIKeys.tsx │ ├── CustomPodcast.tsx │ ├── TopicPodcast.tsx │ └── ui │ │ ├── accordion.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── progress.tsx │ │ ├── select.tsx │ │ ├── slider.tsx │ │ ├── switch.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ └── tooltip.tsx ├── hooks │ └── use-toast.ts ├── index.css ├── lib │ └── utils.ts ├── main.tsx └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .git 4 | .env 5 | __pycache__ 6 | *.pyc 7 | *.pyo 8 | *.pyd 9 | .Python 10 | env 11 | pip-log.txt 12 | pip-delete-this-directory.txt 13 | .tox 14 | .coverage 15 | .coverage.* 16 | .cache 17 | nosetests.xml 18 | coverage.xml 19 | *.cover 20 | *.log 21 | .pytest_cache 22 | .env 23 | .venv 24 | .DS_Store -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | API_TOKEN=your-secure-token-here -------------------------------------------------------------------------------- /.github/workflows/fly-deploy.yml: -------------------------------------------------------------------------------- 1 | # See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ 2 | 3 | name: Fly Deploy 4 | on: 5 | push: 6 | branches: 7 | - main 8 | jobs: 9 | deploy: 10 | name: Deploy app 11 | runs-on: ubuntu-latest 12 | concurrency: deploy-group 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: superfly/flyctl-actions/setup-flyctl@master 16 | - run: flyctl deploy --remote-only 17 | env: 18 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | data/* 26 | *.env 27 | static/* 28 | tsconfig.app.tsbuildinfo 29 | tsconfig.node.tsbuildinfo 30 | data/* 31 | .venv/* 32 | 33 | # New additions 34 | static/audio/* 35 | static/transcripts/* 36 | !static/audio/.gitkeep 37 | !static/transcripts/.gitkeep -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11.7 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Node.js for frontend build 2 | FROM node:20-slim as frontend-builder 3 | 4 | # Install bun 5 | RUN npm install -g bun 6 | 7 | # Set working directory 8 | WORKDIR /app 9 | 10 | # Copy package files 11 | COPY package.json bun.lockb ./ 12 | 13 | # Install dependencies 14 | RUN bun install 15 | 16 | # Copy frontend source 17 | COPY . . 18 | 19 | # Build frontend 20 | RUN bun run build 21 | 22 | # Use Python for the main application 23 | FROM python:3.11-slim 24 | 25 | # Set working directory 26 | WORKDIR /app 27 | 28 | # Install system dependencies 29 | RUN apt-get update && apt-get install -y \ 30 | build-essential \ 31 | ffmpeg \ 32 | && rm -rf /var/lib/apt/lists/* 33 | 34 | # Copy Python requirements 35 | COPY requirements.txt . 36 | 37 | # Install Python dependencies 38 | RUN pip install --no-cache-dir -r requirements.txt 39 | 40 | # Copy application files 41 | COPY . . 42 | 43 | # Copy built frontend from frontend-builder 44 | COPY --from=frontend-builder /app/static ./static 45 | 46 | # Create necessary directories 47 | RUN mkdir -p static/audio static/transcripts data/audio data/transcripts 48 | 49 | # Expose port 50 | EXPOSE 8080 51 | 52 | # Run the application 53 | CMD ["python", "app.py"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## AI Podcast Generator 2 | 3 | A modern web application that automatically generates engaging podcast conversations from URLs or news topics using AI. Powered by [podcastfy.ai](http://podcastfy.ai). 4 | 5 | ### Features 6 | 7 | - Custom podcast generation from multiple URLs 8 | - Real-time progress updates using WebSocket 9 | - Beautiful UI with shadcn/ui components 10 | - API key management for various AI services 11 | - Support for multiple text-to-speech providers 12 | 13 | ### Prerequisites 14 | 15 | - Node.js 18+ 16 | - Python 3.11 17 | - pip 18 | - bun 19 | - pyenv 20 | - Poetry (optional but recommended) 21 | - Fly.io CLI 22 | 23 | ### Development Setup 24 | 25 | 1. Clone the repository: 26 | 27 | ```bash 28 | git clone https://github.com/giulioco/openpod 29 | cd openpod 30 | ``` 31 | 32 | 2. Set up Python environment: 33 | 34 | ```bash 35 | # Install Python 3.11.7 with shared libraries enabled 36 | env PYTHON_CONFIGURE_OPTS="--enable-shared" pyenv install 3.11.7 37 | pyenv local 3.11.7 38 | 39 | # Create and activate virtual environment 40 | python -m venv .venv 41 | source .venv/bin/activate # On Unix/MacOS 42 | # or 43 | .\.venv\Scripts\activate # On Windows 44 | 45 | # Upgrade pip 46 | pip install --upgrade pip 47 | ``` 48 | 49 | 3. Install dependencies: 50 | 51 | ```bash 52 | # Install backend dependencies 53 | pip install -r requirements.txt 54 | 55 | # Install frontend dependencies 56 | bun install 57 | ``` 58 | 59 | 4. Set up environment variables: 60 | 61 | ```bash 62 | cp .env.example .env 63 | # Edit .env with your configuration 64 | ``` 65 | 66 | 5. Start the development servers: 67 | 68 | ```bash 69 | bun dev 70 | ``` 71 | 72 | The application will be available at `http://localhost:5173` 73 | 74 | ### Deployment with Fly.io 75 | 76 | 1. Install the Fly CLI: 77 | 78 | ```bash 79 | curl -L https://fly.io/install.sh | sh 80 | ``` 81 | 82 | 2. Login to Fly: 83 | 84 | ```bash 85 | fly auth login 86 | ``` 87 | 88 | 3. Create a new app: 89 | 90 | ```bash 91 | fly launch 92 | ``` 93 | 94 | 4. Set up environment variables: 95 | 96 | ```bash 97 | # Generate a secure API token 98 | openssl rand -hex 32 99 | 100 | # Set it in Fly.io 101 | fly secrets set API_TOKEN=your_generated_token 102 | ``` 103 | 104 | 5. Create a volume for audio files: 105 | 106 | ```bash 107 | fly volumes create audio_data --size 1 108 | ``` 109 | 110 | 6. Deploy the application: 111 | 112 | ```bash 113 | # Deploy to Fly.io (this will automatically build both frontend and backend) 114 | fly deploy 115 | ``` 116 | 117 | Your API will be available at: 118 | 119 | - Web UI: `https://your-app.fly.dev` 120 | - API Endpoint: `https://your-app.fly.dev/api/generate-from-transcript` 121 | 122 | Make sure to include your API token in requests: 123 | 124 | ```bash 125 | curl -X POST \ 126 | https://your-app.fly.dev/api/generate-from-transcript \ 127 | -H 'Authorization: Bearer your_api_token' \ 128 | -H 'Content-Type: application/json' \ 129 | -d '{ 130 | "transcript": "Your transcript here", 131 | "podcast_name": "My Podcast", 132 | "google_key": "your_google_api_key" 133 | }' 134 | ``` 135 | 136 | ### Project Structure 137 | 138 | ``` 139 | . 140 | ├── src/ # Frontend source code 141 | │ ├── components/ # React components 142 | │ ├── lib/ # Utility functions 143 | │ └── hooks/ # Custom React hooks 144 | ├── app.py # Flask backend 145 | ├── requirements.txt # Python dependencies 146 | └── fly.toml # Fly.io configuration 147 | ``` 148 | 149 | ### Contributing 150 | 151 | 1. Fork the repository 152 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 153 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 154 | 4. Push to the branch (`git push origin feature/amazing-feature`) 155 | 5. Open a Pull Request 156 | 157 | ### API Endpoints 158 | 159 | #### Generate Podcast from Transcript 160 | 161 | A secure endpoint that generates podcasts from existing transcripts. Requires API token authentication. 162 | 163 | ##### Authentication 164 | 165 | 1. Generate a secure API token and add it to your `.env` file: 166 | 167 | ```bash 168 | # Generate a secure token 169 | openssl rand -hex 32 170 | 171 | # Add to .env 172 | API_TOKEN=your_generated_token 173 | ``` 174 | 175 | ##### Endpoint Details 176 | 177 | - **URL**: `/api/generate-from-transcript` 178 | - **Method**: `POST` 179 | - **Auth Required**: Yes (Bearer Token) 180 | - **Headers**: 181 | ``` 182 | Authorization: Bearer your_api_token 183 | Content-Type: application/json 184 | ``` 185 | 186 | ##### Request Body 187 | 188 | ```json 189 | { 190 | "transcript": "Your conversation transcript here", 191 | "tts_model": "geminimulti", // optional 192 | "creativity": 0.7, // optional 193 | "conversation_style": ["casual", "humorous"], // optional 194 | "roles_person1": "Host", // optional 195 | "roles_person2": "Guest", // optional 196 | "dialogue_structure": ["Introduction", "Content", "Conclusion"], // optional 197 | "podcast_name": "My Custom Podcast", // optional 198 | "podcast_tagline": "", // optional 199 | "output_language": "English", // optional 200 | "user_instructions": "", // optional 201 | "engagement_techniques": [], // optional 202 | "ending_message": "Thank you for listening", // optional 203 | "google_key": "your_google_api_key" // required for gemini/geminimulti 204 | } 205 | ``` 206 | 207 | ##### Response 208 | 209 | Success Response: 210 | 211 | ```json 212 | { 213 | "success": true, 214 | "audio_url": "/audio/transcript_podcast_abc123.mp3", 215 | "transcript": "Processed transcript..." // if available 216 | } 217 | ``` 218 | 219 | Error Response: 220 | 221 | ```json 222 | { 223 | "error": "Error message here" 224 | } 225 | ``` 226 | 227 | ##### Example Usage 228 | 229 | ```bash 230 | curl -X POST \ 231 | http://your-server/api/generate-from-transcript \ 232 | -H 'Authorization: Bearer your_api_token' \ 233 | -H 'Content-Type: application/json' \ 234 | -d '{ 235 | "transcript": " Hi and welcome to the podcast! \n Thanks for having me! \n Let'\''s get started with our first topic. ", 236 | "podcast_name": "My Custom Podcast", 237 | "google_key": "your_google_api_key" 238 | }' 239 | ``` 240 | 241 | For Windows PowerShell users: 242 | 243 | ```powershell 244 | $body = @{ 245 | transcript = " Hi and welcome to the podcast! `n Thanks for having me! `n Let's get started with our first topic. " 246 | podcast_name = "My Custom Podcast" 247 | google_key = "your_google_api_key" 248 | } | ConvertTo-Json 249 | 250 | Invoke-RestMethod -Method Post ` 251 | -Uri "http://your-server/api/generate-from-transcript" ` 252 | -Headers @{ 253 | "Authorization" = "Bearer your_api_token" 254 | "Content-Type" = "application/json" 255 | } ` 256 | -Body $body 257 | ``` 258 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, request, jsonify, send_file, session, render_template, send_from_directory 2 | from flask_socketio import SocketIO, emit 3 | from flask_cors import CORS 4 | from dotenv import load_dotenv 5 | import os 6 | from podcastfy.client import generate_podcast 7 | import shutil 8 | from contextlib import contextmanager 9 | import tempfile 10 | from functools import wraps 11 | import jwt 12 | from datetime import datetime, timedelta 13 | from pathlib import Path 14 | 15 | # Load environment variables with explicit path and override 16 | env_path = Path('.') / '.env' 17 | load_dotenv(dotenv_path=env_path, override=True) 18 | 19 | # Create required directories 20 | TEMP_DIR = '/tmp/audio' 21 | STATIC_DIR = os.path.join(os.path.dirname(__file__), 'static') 22 | AUDIO_DIR = os.path.join(STATIC_DIR, 'audio') 23 | TRANSCRIPT_DIR = os.path.join(STATIC_DIR, 'transcripts') 24 | 25 | os.makedirs(TEMP_DIR, exist_ok=True) 26 | os.makedirs(AUDIO_DIR, exist_ok=True) 27 | os.makedirs(TRANSCRIPT_DIR, exist_ok=True) 28 | 29 | app = Flask(__name__, 30 | static_folder='static', 31 | static_url_path='/static' 32 | ) 33 | app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', os.urandom(24)) 34 | 35 | # Load API token after ensuring .env is loaded 36 | API_TOKEN = os.getenv('API_TOKEN') 37 | if not API_TOKEN: 38 | raise ValueError("API_TOKEN must be set in .env file") 39 | 40 | # Enable CORS in development 41 | if app.debug: 42 | CORS(app) 43 | # Serve index.html from root directory in development 44 | @app.route('/') 45 | def index(): 46 | return send_file('../index.html') 47 | else: 48 | # Serve static files in production 49 | @app.route('/', defaults={'path': ''}) 50 | @app.route('/') 51 | def serve(path): 52 | if path != "" and os.path.exists(os.path.join(app.static_folder, path)): 53 | return send_from_directory(app.static_folder, path) 54 | return send_from_directory(app.static_folder, 'index.html') 55 | 56 | socketio = SocketIO(app, cors_allowed_origins="*") 57 | 58 | def require_api_token(f): 59 | @wraps(f) 60 | def decorated(*args, **kwargs): 61 | token = request.headers.get('Authorization') 62 | 63 | if not token: 64 | return jsonify({'error': 'No token provided'}), 401 65 | 66 | if not token.startswith('Bearer '): 67 | return jsonify({'error': 'Invalid token format'}), 401 68 | 69 | token = token.split('Bearer ')[1] 70 | 71 | if token != API_TOKEN: 72 | return jsonify({'error': f'Invalid token'}), 401 73 | 74 | return f(*args, **kwargs) 75 | return decorated 76 | 77 | @contextmanager 78 | def temporary_env(temp_env): 79 | """Temporarily set environment variables and restore them afterwards.""" 80 | original_env = dict(os.environ) 81 | os.environ.update(temp_env) 82 | try: 83 | yield 84 | finally: 85 | os.environ.clear() 86 | os.environ.update(original_env) 87 | 88 | @contextmanager 89 | def temporary_env_file(env_vars): 90 | """Creates a temporary .env file with the provided variables.""" 91 | with tempfile.NamedTemporaryFile(mode='w', suffix='.env', delete=False) as temp_env: 92 | # Write variables to temp file 93 | for key, value in env_vars.items(): 94 | temp_env.write(f"{key}={value}\n") 95 | temp_env.flush() 96 | 97 | # Store original env file path if it exists 98 | original_env_path = os.getenv('ENV_FILE') 99 | 100 | try: 101 | # Set the ENV_FILE environment variable to point to our temp file 102 | os.environ['ENV_FILE'] = temp_env.name 103 | yield 104 | finally: 105 | # Restore original ENV_FILE if it existed 106 | if original_env_path: 107 | os.environ['ENV_FILE'] = original_env_path 108 | else: 109 | os.environ.pop('ENV_FILE', None) 110 | # Clean up temp file 111 | os.unlink(temp_env.name) 112 | 113 | @socketio.on('connect') 114 | def handle_connect(): 115 | print("\n=== Socket Connected ===") 116 | print(f"Client ID: {request.sid}") 117 | 118 | @socketio.on('disconnect') 119 | def handle_disconnect(): 120 | print("\n=== Socket Disconnected ===") 121 | print(f"Client ID: {request.sid}") 122 | 123 | @socketio.on('generate_podcast') 124 | def handle_generate_podcast(data): 125 | try: 126 | print("\n=== Starting Podcast Generation ===") 127 | emit('status', "Starting podcast generation...") 128 | 129 | # Get the selected TTS model 130 | tts_model = data.get('tts_model', 'geminimulti') 131 | print(f"\nSelected TTS Model: {tts_model}") 132 | 133 | # Set up API keys based on selected model 134 | api_key_label = None 135 | if tts_model in ['gemini', 'geminimulti']: 136 | api_key = data.get('google_key') 137 | if not api_key: 138 | raise ValueError("Missing Google API key") 139 | os.environ['GOOGLE_API_KEY'] = api_key 140 | os.environ['GEMINI_API_KEY'] = api_key 141 | api_key_label = 'GEMINI_API_KEY' 142 | 143 | conversation_config = { 144 | 'creativity': float(data.get('creativity', 0.7)), 145 | 'conversation_style': data.get('conversation_style', []), 146 | 'roles_person1': data.get('roles_person1', 'Interviewer'), 147 | 'roles_person2': data.get('roles_person2', 'Subject matter expert'), 148 | 'dialogue_structure': data.get('dialogue_structure', []), 149 | 'podcast_name': data.get('name'), 150 | 'podcast_tagline': data.get('tagline'), 151 | 'output_language': 'English', 152 | 'user_instructions': data.get('user_instructions'), 153 | 'engagement_techniques': data.get('engagement_techniques', []), 154 | 'text_to_speech': { 155 | 'temp_audio_dir': TEMP_DIR, 156 | 'ending_message': "Thank you for listening to this episode.", 157 | 'default_tts_model': 'geminimulti', 158 | 'audio_format': 'mp3' 159 | } 160 | } 161 | 162 | emit('status', "Generating podcast content...") 163 | emit('progress', {'progress': 30, 'message': 'Generating podcast content...'}) 164 | 165 | # Add image_paths parameter if provided 166 | image_paths = data.get('image_urls', []) 167 | 168 | result = generate_podcast( 169 | urls=data.get('urls', []), 170 | conversation_config=conversation_config, 171 | tts_model=tts_model, 172 | longform=bool(data.get('is_long_form', False)), 173 | api_key_label=api_key_label, # This tells podcastfy which env var to use 174 | image_paths=image_paths if image_paths else None # Only pass if not empty 175 | ) 176 | 177 | emit('status', "Processing audio...") 178 | emit('progress', {'progress': 90, 'message': 'Processing final audio...'}) 179 | 180 | # Handle the result 181 | if isinstance(result, str) and os.path.isfile(result): 182 | filename = f"podcast_{os.urandom(8).hex()}.mp3" 183 | output_path = os.path.join(TEMP_DIR, filename) 184 | shutil.copy2(result, output_path) 185 | emit('progress', {'progress': 100, 'message': 'Podcast generation complete!'}) 186 | emit('complete', { 187 | 'audioUrl': f'/audio/{filename}', 188 | 'transcript': None 189 | }, room=request.sid) 190 | elif hasattr(result, 'audio_path'): 191 | filename = f"podcast_{os.urandom(8).hex()}.mp3" 192 | output_path = os.path.join(TEMP_DIR, filename) 193 | shutil.copy2(result.audio_path, output_path) 194 | emit('complete', { 195 | 'audioUrl': f'/audio/{filename}', 196 | 'transcript': result.details if hasattr(result, 'details') else None 197 | }, room=request.sid) 198 | else: 199 | raise Exception('Invalid result format') 200 | 201 | except Exception as e: 202 | print(f"\nError in handle_generate_podcast: {str(e)}") 203 | print(f"Error type: {type(e)}") 204 | import traceback 205 | print(f"Traceback: {traceback.format_exc()}") 206 | emit('error', {'message': str(e)}, room=request.sid) 207 | 208 | @socketio.on('generate_news_podcast') 209 | def handle_generate_news_podcast(data): 210 | try: 211 | print("\n=== Starting News Podcast Generation ===") 212 | emit('status', "Starting news podcast generation...") 213 | 214 | # Get the API key and topics 215 | api_key = data.get('google_key') 216 | topics = data.get('topics') 217 | 218 | if not api_key: 219 | raise ValueError("Missing Google API key") 220 | if not topics: 221 | raise ValueError("No topics provided") 222 | 223 | print(f"Topics: {topics}") 224 | 225 | # Set environment variables 226 | os.environ['GOOGLE_API_KEY'] = api_key 227 | os.environ['GEMINI_API_KEY'] = api_key 228 | 229 | # Test the API key 230 | try: 231 | import google.generativeai as genai 232 | genai.configure(api_key=api_key) 233 | model = genai.GenerativeModel('gemini-pro') 234 | response = model.generate_content("Test message") 235 | print("\n=== API Test Successful ===") 236 | except Exception as e: 237 | print("\n=== API Test Failed ===") 238 | print(f"Error: {str(e)}") 239 | raise 240 | 241 | emit('status', "Generating news podcast...") 242 | emit('progress', {'progress': 30, 'message': 'Generating content...'}) 243 | 244 | # Use a different function for news podcasts 245 | result = generate_podcast( 246 | topic=topics, 247 | tts_model='gemini', 248 | api_key_label='GEMINI_API_KEY' 249 | ) 250 | 251 | emit('status', "Processing audio...") 252 | emit('progress', {'progress': 90, 'message': 'Processing final audio...'}) 253 | 254 | # Handle the result 255 | if isinstance(result, str) and os.path.isfile(result): 256 | filename = f"news_podcast_{os.urandom(8).hex()}.mp3" 257 | output_path = os.path.join(TEMP_DIR, filename) 258 | shutil.copy2(result, output_path) 259 | emit('progress', {'progress': 100, 'message': 'Podcast generation complete!'}) 260 | emit('complete', { 261 | 'audioUrl': f'/audio/{filename}', 262 | 'transcript': None 263 | }, room=request.sid) 264 | elif hasattr(result, 'audio_path'): 265 | filename = f"news_podcast_{os.urandom(8).hex()}.mp3" 266 | output_path = os.path.join(TEMP_DIR, filename) 267 | shutil.copy2(result.audio_path, output_path) 268 | emit('complete', { 269 | 'audioUrl': f'/audio/{filename}', 270 | 'transcript': result.details if hasattr(result, 'details') else None 271 | }, room=request.sid) 272 | else: 273 | raise Exception('Invalid result format') 274 | 275 | except Exception as e: 276 | print(f"\nError in handle_generate_news_podcast: {str(e)}") 277 | print(f"Error type: {type(e)}") 278 | import traceback 279 | print(f"Traceback: {traceback.format_exc()}") 280 | emit('error', {'message': str(e)}, room=request.sid) 281 | 282 | @app.route('/audio/') 283 | def serve_audio(filename): 284 | """Serve generated audio files""" 285 | # Check all possible audio paths 286 | possible_paths = [ 287 | os.path.join('data/audio', filename), 288 | os.path.join(AUDIO_DIR, filename), 289 | # Add any additional mounted volume paths here 290 | "/app/data/audio/" + filename, 291 | ] 292 | 293 | for path in possible_paths: 294 | if os.path.exists(path): 295 | print(f"Serving audio from: {path}") 296 | return send_file(path) 297 | 298 | return jsonify({'error': 'Audio file not found'}), 404 299 | 300 | @app.route('/api/generate-from-transcript', methods=['POST']) 301 | @require_api_token 302 | def generate_from_transcript(): 303 | try: 304 | data = request.get_json() 305 | 306 | # Validate required fields 307 | if not data or 'transcript' not in data: 308 | return jsonify({'error': 'Missing transcript in request body'}), 400 309 | 310 | # Extract parameters from request 311 | transcript = data['transcript'] 312 | tts_model = data.get('tts_model', 'geminimulti') 313 | 314 | # Create temporary transcript file 315 | with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as temp_file: 316 | temp_file.write(transcript) 317 | transcript_path = temp_file.name 318 | 319 | print(f"Created temporary transcript file: {transcript_path}") 320 | 321 | # Build conversation config from request data or use defaults 322 | conversation_config = { 323 | 'creativity': float(data.get('creativity', 0.7)), 324 | 'conversation_style': data.get('conversation_style', ['casual']), 325 | 'roles_person1': data.get('roles_person1', 'Host'), 326 | 'roles_person2': data.get('roles_person2', 'Guest'), 327 | 'dialogue_structure': data.get('dialogue_structure', ['Introduction', 'Content', 'Conclusion']), 328 | 'podcast_name': data.get('podcast_name', 'Custom Transcript Podcast'), 329 | 'podcast_tagline': data.get('podcast_tagline', ''), 330 | 'output_language': data.get('output_language', 'English'), 331 | 'user_instructions': data.get('user_instructions', ''), 332 | 'engagement_techniques': data.get('engagement_techniques', []), 333 | 'text_to_speech': { 334 | 'temp_audio_dir': 'data/audio', # Let podcastfy use its default 335 | 'ending_message': data.get('ending_message', "Thank you for listening to this episode."), 336 | 'default_tts_model': tts_model, 337 | 'audio_format': 'mp3', 338 | 'output_directories': { 339 | 'audio': 'data/audio', 340 | 'transcripts': 'data/transcripts' 341 | } 342 | } 343 | } 344 | 345 | # Set up API keys if needed 346 | api_key_label = None 347 | if tts_model in ['gemini', 'geminimulti']: 348 | api_key = data.get('google_key') 349 | if not api_key: 350 | return jsonify({'error': 'Missing Google API key'}), 400 351 | os.environ['GOOGLE_API_KEY'] = api_key 352 | os.environ['GEMINI_API_KEY'] = api_key 353 | api_key_label = 'GEMINI_API_KEY' 354 | 355 | # Generate the podcast 356 | result = generate_podcast( 357 | transcript_file=transcript_path, 358 | conversation_config=conversation_config, 359 | tts_model=tts_model, 360 | api_key_label=api_key_label 361 | ) 362 | 363 | # Clean up temporary file 364 | try: 365 | os.unlink(transcript_path) 366 | print(f"Cleaned up temporary transcript file: {transcript_path}") 367 | except Exception as e: 368 | print(f"Warning: Could not delete temporary file {transcript_path}: {e}") 369 | 370 | # Handle the result 371 | if isinstance(result, str): 372 | return jsonify({ 373 | 'success': True, 374 | 'audio_url': f'/audio/{os.path.basename(result)}', 375 | }) 376 | elif hasattr(result, 'audio_path'): 377 | print(f"Audio file path: {result.audio_path}") 378 | print(f"File exists: {os.path.exists(result.audio_path)}") 379 | return jsonify({ 380 | 'success': True, 381 | 'audio_url': f'/audio/{os.path.basename(result.audio_path)}', 382 | 'transcript': result.details if hasattr(result, 'details') else None 383 | }) 384 | else: 385 | return jsonify({'error': 'Invalid result format'}), 500 386 | 387 | except Exception as e: 388 | print(f"\nError in generate_from_transcript: {str(e)}") 389 | print(f"Error type: {type(e)}") 390 | import traceback 391 | print(f"Traceback: {traceback.format_exc()}") 392 | return jsonify({'error': str(e)}), 500 393 | 394 | @app.route('/api/test-env', methods=['GET']) 395 | def test_env(): 396 | """Test endpoint to verify environment variables""" 397 | return jsonify({ 398 | 'api_token_set': bool(API_TOKEN), 399 | 'api_token_length': len(API_TOKEN) if API_TOKEN else 0, 400 | }) 401 | 402 | if __name__ == '__main__': 403 | port = int(os.getenv('PORT', 8080)) 404 | socketio.run(app, 405 | host='0.0.0.0', 406 | port=port, 407 | debug=False, # Set to False in production 408 | allow_unsafe_werkzeug=True) -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/giulioco/openpod/c06a347879e5dd672aca98c913ac17727437e89f/bun.lockb -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import globals from 'globals'; 3 | import reactHooks from 'eslint-plugin-react-hooks'; 4 | import reactRefresh from 'eslint-plugin-react-refresh'; 5 | import tseslint from 'typescript-eslint'; 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | } 28 | ); 29 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for openpod on 2024-11-16T12:01:28-05:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = 'openpod' 7 | primary_region = 'ewr' 8 | 9 | [build] 10 | 11 | [http_service] 12 | internal_port = 8080 13 | force_https = true 14 | auto_stop_machines = 'stop' 15 | auto_start_machines = true 16 | min_machines_running = 0 17 | processes = ['app'] 18 | 19 | [[vm]] 20 | memory = '1gb' 21 | cpu_kind = 'shared' 22 | cpus = 1 23 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | OpenPod 8 | 60 | 61 | 62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ai-podcast-generator", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "concurrently \"python app.py\" \"vite\"", 8 | "dev:frontend": "vite", 9 | "dev:backend": "python app.py", 10 | "build": "tsc -b && vite build", 11 | "lint": "eslint .", 12 | "preview": "vite preview", 13 | "deploy": "fly deploy" 14 | }, 15 | "dependencies": { 16 | "@hookform/resolvers": "^3.3.4", 17 | "@radix-ui/react-accordion": "^1.2.1", 18 | "@radix-ui/react-alert-dialog": "^1.0.5", 19 | "@radix-ui/react-dialog": "^1.1.2", 20 | "@radix-ui/react-icons": "^1.3.2", 21 | "@radix-ui/react-label": "^2.1.0", 22 | "@radix-ui/react-progress": "^1.1.0", 23 | "@radix-ui/react-select": "^2.1.2", 24 | "@radix-ui/react-slider": "^1.2.1", 25 | "@radix-ui/react-slot": "^1.1.0", 26 | "@radix-ui/react-switch": "^1.1.1", 27 | "@radix-ui/react-tabs": "^1.1.1", 28 | "@radix-ui/react-toast": "^1.2.2", 29 | "@radix-ui/react-tooltip": "^1.1.4", 30 | "class-variance-authority": "^0.7.0", 31 | "clsx": "^2.1.1", 32 | "lucide-react": "^0.460.0", 33 | "react": "^18.2.0", 34 | "react-dom": "^18.2.0", 35 | "react-hook-form": "^7.51.0", 36 | "shadcn-ui": "^0.9.3", 37 | "socket.io-client": "^4.7.4", 38 | "tailwind-merge": "^2.5.4", 39 | "tailwindcss-animate": "^1.0.7", 40 | "zod": "^3.22.4" 41 | }, 42 | "devDependencies": { 43 | "@types/node": "^20.11.24", 44 | "@types/react": "^18.2.56", 45 | "@types/react-dom": "^18.2.19", 46 | "@typescript-eslint/eslint-plugin": "^7.0.2", 47 | "@typescript-eslint/parser": "^7.0.2", 48 | "@vitejs/plugin-react": "^4.2.1", 49 | "autoprefixer": "^10.4.17", 50 | "concurrently": "^9.1.0", 51 | "eslint": "^8.56.0", 52 | "eslint-plugin-react-hooks": "^4.6.0", 53 | "eslint-plugin-react-refresh": "^0.4.5", 54 | "postcss": "^8.4.35", 55 | "tailwindcss": "^3.4.1", 56 | "typescript": "^5.2.2", 57 | "vite": "^5.1.4" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohappyeyeballs==2.4.3 2 | aiohttp==3.11.2 3 | aiosignal==1.3.1 4 | alabaster==1.0.0 5 | annotated-types==0.7.0 6 | anyio==4.6.2.post1 7 | attrs==24.2.0 8 | babel==2.16.0 9 | beautifulsoup4==4.12.3 10 | bidict==0.23.1 11 | bleach==6.2.0 12 | blinker==1.9.0 13 | cachetools==5.5.0 14 | certifi==2024.8.30 15 | charset-normalizer==3.4.0 16 | click==8.1.7 17 | Cython==3.0.11 18 | dataclasses-json==0.6.7 19 | defusedxml==0.7.1 20 | distro==1.9.0 21 | docstring_parser==0.16 22 | docutils==0.21.2 23 | edge-tts==6.1.15 24 | elevenlabs==1.12.1 25 | execnet==2.1.1 26 | fastjsonschema==2.20.0 27 | ffmpeg==1.4 28 | filelock==3.16.1 29 | Flask==3.0.0 30 | Flask-Cors==5.0.0 31 | Flask-SocketIO==5.4.1 32 | frozenlist==1.5.0 33 | fsspec==2024.10.0 34 | fuzzywuzzy==0.18.0 35 | google-ai-generativelanguage==0.6.10 36 | google-api-core==2.23.0 37 | google-api-python-client==2.153.0 38 | google-auth==2.36.0 39 | google-auth-httplib2==0.2.0 40 | google-cloud-aiplatform==1.72.0 41 | google-cloud-bigquery==3.27.0 42 | google-cloud-core==2.4.1 43 | google-cloud-resource-manager==1.13.1 44 | google-cloud-storage==2.18.2 45 | google-cloud-texttospeech==2.21.1 46 | google-crc32c==1.6.0 47 | google-generativeai==0.8.3 48 | google-resumable-media==2.7.2 49 | googleapis-common-protos==1.66.0 50 | grpc-google-iam-v1==0.13.1 51 | grpcio==1.67.1 52 | grpcio-status==1.67.1 53 | gunicorn==23.0.0 54 | h11==0.14.0 55 | httpcore==1.0.7 56 | httplib2==0.22.0 57 | httpx==0.27.2 58 | httpx-sse==0.4.0 59 | huggingface-hub==0.26.2 60 | idna==3.10 61 | imagesize==1.4.1 62 | importlib_metadata==8.5.0 63 | iniconfig==2.0.0 64 | itsdangerous==2.2.0 65 | Jinja2==3.1.4 66 | jiter==0.7.1 67 | jsonpatch==1.33 68 | jsonpointer==3.0.0 69 | jsonschema==4.23.0 70 | jsonschema-specifications==2024.10.1 71 | jupyter_client==8.6.3 72 | jupyter_core==5.7.2 73 | jupyterlab_pygments==0.3.0 74 | langchain==0.3.7 75 | langchain-community==0.3.7 76 | langchain-core==0.3.19 77 | langchain-google-genai==2.0.4 78 | langchain-google-vertexai==2.0.7 79 | langchain-text-splitters==0.3.2 80 | langsmith==0.1.143 81 | Levenshtein==0.26.1 82 | litellm==1.52.8 83 | markdown-it-py==3.0.0 84 | MarkupSafe==3.0.2 85 | marshmallow==3.23.1 86 | mdurl==0.1.2 87 | mistune==3.0.2 88 | multidict==6.1.0 89 | mypy-extensions==1.0.0 90 | nbclient==0.10.0 91 | nbconvert==7.16.4 92 | nbformat==5.10.4 93 | nbsphinx==0.9.5 94 | nest-asyncio==1.6.0 95 | numpy==1.26.4 96 | openai==1.54.2 97 | orjson==3.10.11 98 | packaging==24.2 99 | pandas==2.2.3 100 | pandoc==2.4 101 | pandocfilters==1.5.1 102 | platformdirs==4.3.6 103 | pluggy==1.5.0 104 | plumbum==1.9.0 105 | ply==3.11 106 | podcastfy==0.4.1 107 | propcache==0.2.0 108 | proto-plus==1.25.0 109 | protobuf==5.28.3 110 | pyasn1==0.6.1 111 | pyasn1_modules==0.4.1 112 | pydantic==2.9.2 113 | pydantic-settings==2.6.1 114 | pydantic_core==2.23.4 115 | pydub==0.25.1 116 | Pygments==2.18.0 117 | PyJWT==2.8.0 118 | PyMuPDF==1.24.13 119 | pyparsing==3.2.0 120 | pytest==8.3.3 121 | pytest-xdist==3.6.1 122 | python-dateutil==2.9.0.post0 123 | python-dotenv==1.0.1 124 | python-engineio==4.10.1 125 | python-Levenshtein==0.26.1 126 | python-socketio==5.11.4 127 | pytz==2024.2 128 | PyYAML==6.0.2 129 | pyzmq==26.2.0 130 | RapidFuzz==3.10.1 131 | referencing==0.35.1 132 | regex==2024.11.6 133 | requests==2.32.3 134 | requests-toolbelt==1.0.0 135 | rich==13.9.4 136 | rpds-py==0.21.0 137 | rsa==4.9 138 | shapely==2.0.6 139 | shellingham==1.5.4 140 | simple-websocket==1.1.0 141 | six==1.16.0 142 | sniffio==1.3.1 143 | snowballstemmer==2.2.0 144 | soupsieve==2.6 145 | Sphinx==8.1.3 146 | sphinx-autodoc-typehints==2.5.0 147 | sphinx-rtd-theme==3.0.2 148 | sphinxcontrib-applehelp==2.0.0 149 | sphinxcontrib-devhelp==2.0.0 150 | sphinxcontrib-htmlhelp==2.1.0 151 | sphinxcontrib-jquery==4.1 152 | sphinxcontrib-jsmath==1.0.1 153 | sphinxcontrib-qthelp==2.0.0 154 | sphinxcontrib-serializinghtml==2.0.0 155 | SQLAlchemy==2.0.35 156 | tenacity==9.0.0 157 | tiktoken==0.8.0 158 | tinycss2==1.4.0 159 | tokenizers==0.20.3 160 | tornado==6.4.1 161 | tqdm==4.67.0 162 | traitlets==5.14.3 163 | typer==0.12.5 164 | types-PyYAML==6.0.12.20240917 165 | typing-inspect==0.9.0 166 | typing_extensions==4.12.2 167 | tzdata==2024.2 168 | uritemplate==4.1.1 169 | urllib3==2.2.3 170 | webencodings==0.5.1 171 | websockets==14.1 172 | Werkzeug==3.1.3 173 | wsproto==1.2.0 174 | yarl==1.17.1 175 | youtube-transcript-api==0.6.2 176 | zipp==3.21.0 177 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.11 -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 3 | import { CustomPodcast } from "@/components/CustomPodcast"; 4 | import { APIKeys } from "@/components/APIKeys"; 5 | import { TopicPodcast } from "@/components/TopicPodcast"; 6 | import { Toaster } from "@/components/ui/toaster"; 7 | 8 | export default function App() { 9 | const [activeTab, setActiveTab] = useState("custom"); 10 | 11 | useEffect(() => { 12 | const script = document.createElement("script"); 13 | script.src = "https://buttons.github.io/buttons.js"; 14 | script.async = true; 15 | document.body.appendChild(script); 16 | 17 | return () => { 18 | document.body.removeChild(script); 19 | }; 20 | }, []); 21 | 22 | return ( 23 |
24 |
25 |
26 |

OpenPod

27 |

28 | Transform any content into engaging podcast conversations 29 |

30 |
31 |

32 | Powered by{" "} 33 | 39 | Podcastfy 40 | 41 |

42 | 50 | Star 51 | 52 |
53 |
54 | 55 |
56 |
57 | 58 | 59 | Custom Podcast 60 | Topic Research 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 |
70 | 71 |
72 | 73 |
74 |
75 |
76 | 77 |
78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /src/components/APIKeys.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Input } from "@/components/ui/input"; 4 | import { Label } from "@/components/ui/label"; 5 | import { Card } from "@/components/ui/card"; 6 | import { useToast } from "@/hooks/use-toast"; 7 | import { Eye, EyeOff, HelpCircle } from "lucide-react"; 8 | import { 9 | Dialog, 10 | DialogContent, 11 | DialogDescription, 12 | DialogHeader, 13 | DialogTitle, 14 | DialogTrigger, 15 | } from "@/components/ui/dialog"; 16 | 17 | const API_KEY_INSTRUCTIONS = { 18 | google: { 19 | title: "Get Google API Key", 20 | steps: [ 21 | { 22 | title: "Create Google Cloud Project", 23 | steps: [ 24 | "Go to Google Cloud Console (console.cloud.google.com)", 25 | "Create a new project or select an existing one", 26 | "Enable billing for your project", 27 | ], 28 | }, 29 | { 30 | title: "Enable Required APIs", 31 | steps: [ 32 | "Go to 'APIs & Services' > 'Library'", 33 | "Search for and enable 'Vertex AI API'", 34 | "Search for and enable 'Cloud Text-to-Speech API'", 35 | "Search for and enable 'Gemini API'", 36 | ], 37 | }, 38 | { 39 | title: "Create API Key", 40 | steps: [ 41 | "Go to 'APIs & Services' > 'Credentials'", 42 | "Click 'Create Credentials' > 'API Key'", 43 | "Copy your new API key", 44 | "Optional: Restrict the key to only the APIs you enabled", 45 | ], 46 | }, 47 | ], 48 | }, 49 | openai: { 50 | title: "Get OpenAI API Key", 51 | steps: [ 52 | { 53 | title: "Create OpenAI Account", 54 | steps: [ 55 | "Go to platform.openai.com", 56 | "Sign up or log in to your account", 57 | ], 58 | }, 59 | { 60 | title: "Generate API Key", 61 | steps: [ 62 | "Go to 'API Keys' section", 63 | "Click 'Create new secret key'", 64 | "Give your key a name (optional)", 65 | "Copy your API key immediately (you won't be able to see it again)", 66 | ], 67 | }, 68 | ], 69 | }, 70 | elevenlabs: { 71 | title: "Get ElevenLabs API Key", 72 | steps: [ 73 | { 74 | title: "Create ElevenLabs Account", 75 | steps: ["Go to elevenlabs.io", "Sign up or log in to your account"], 76 | }, 77 | { 78 | title: "Get API Key", 79 | steps: [ 80 | "Go to your Profile Settings", 81 | "Find the 'API Key' section", 82 | "Copy your API key", 83 | ], 84 | }, 85 | ], 86 | }, 87 | }; 88 | 89 | function InstructionsDialog({ 90 | instructions, 91 | }: { 92 | instructions: (typeof API_KEY_INSTRUCTIONS)[keyof typeof API_KEY_INSTRUCTIONS]; 93 | }) { 94 | return ( 95 | 96 | 97 | 100 | 101 | 102 | 103 | {instructions.title} 104 | 105 | Follow these steps to get your API key 106 | 107 | 108 |
109 | {instructions.steps.map((section, idx) => ( 110 |
111 |

{section.title}

112 |
    113 | {section.steps.map((step, stepIdx) => ( 114 |
  1. 115 | {step} 116 |
  2. 117 | ))} 118 |
119 |
120 | ))} 121 |
122 |
123 |
124 | ); 125 | } 126 | 127 | export function APIKeys() { 128 | const [keys, setKeys] = useState({ 129 | google: "", 130 | openai: "", 131 | elevenlabs: "", 132 | }); 133 | const [showKeys, setShowKeys] = useState({ 134 | google: false, 135 | openai: false, 136 | elevenlabs: false, 137 | }); 138 | const { toast } = useToast(); 139 | 140 | // Load saved keys on component mount 141 | useEffect(() => { 142 | const loadedKeys = { 143 | google: sessionStorage.getItem("google_key") || "", 144 | openai: sessionStorage.getItem("openai_key") || "", 145 | elevenlabs: sessionStorage.getItem("elevenlabs_key") || "", 146 | }; 147 | setKeys(loadedKeys); 148 | }, []); 149 | 150 | const saveKey = (type: keyof typeof keys) => { 151 | if (!keys[type].trim()) { 152 | toast({ 153 | title: "Error", 154 | description: "Please enter an API key", 155 | variant: "destructive", 156 | }); 157 | return; 158 | } 159 | 160 | sessionStorage.setItem(`${type}_key`, keys[type]); 161 | toast({ 162 | title: "API Key Saved", 163 | description: `Your ${type} API key has been saved for this session.`, 164 | }); 165 | }; 166 | 167 | const toggleShowKey = (type: keyof typeof keys) => { 168 | setShowKeys((prev) => ({ 169 | ...prev, 170 | [type]: !prev[type], 171 | })); 172 | }; 173 | 174 | return ( 175 | 176 |

API Keys

177 | 178 |
179 | {Object.entries(keys).map(([type, value]) => ( 180 |
181 |
182 | 190 | 197 |
198 |
199 |
200 | 207 | setKeys((prev) => ({ ...prev, [type]: e.target.value })) 208 | } 209 | className="pr-10" 210 | /> 211 | 224 |
225 | 228 |
229 |
230 | ))} 231 |
232 |
233 | ); 234 | } 235 | -------------------------------------------------------------------------------- /src/components/CustomPodcast.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef } from "react"; 2 | import { useForm } from "react-hook-form"; 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import * as z from "zod"; 5 | import { Loader2, Play, Download, X, ImagePlus } from "lucide-react"; 6 | import { Button } from "@/components/ui/button"; 7 | import { Textarea } from "@/components/ui/textarea"; 8 | import { Input } from "@/components/ui/input"; 9 | import { Label } from "@/components/ui/label"; 10 | import { Progress } from "@/components/ui/progress"; 11 | import { Card } from "@/components/ui/card"; 12 | import { io } from "socket.io-client"; 13 | import { 14 | Accordion, 15 | AccordionContent, 16 | AccordionItem, 17 | AccordionTrigger, 18 | } from "@/components/ui/accordion"; 19 | import { useToast } from "@/hooks/use-toast"; 20 | import { Slider } from "@/components/ui/slider"; 21 | import { Badge } from "@/components/ui/badge"; 22 | import { 23 | Select, 24 | SelectTrigger, 25 | SelectValue, 26 | SelectContent, 27 | SelectItem, 28 | } from "@/components/ui/select"; 29 | import { 30 | Tooltip, 31 | TooltipContent, 32 | TooltipProvider, 33 | TooltipTrigger, 34 | } from "@/components/ui/tooltip"; 35 | import { InfoCircledIcon } from "@radix-ui/react-icons"; 36 | import { Switch } from "@/components/ui/switch"; 37 | 38 | type ConversationStyle = 39 | | "Engaging" 40 | | "Fast-paced" 41 | | "Enthusiastic" 42 | | "Educational" 43 | | "Casual" 44 | | "Professional" 45 | | "Friendly"; 46 | type DialogueStructure = 47 | | "Topic Introduction" 48 | | "Summary" 49 | | "Discussions" 50 | | "Q&A" 51 | | "Farewell"; 52 | type EngagementTechnique = 53 | | "Questions" 54 | | "Testimonials" 55 | | "Quotes" 56 | | "Anecdotes" 57 | | "Analogies" 58 | | "Humor"; 59 | type TTSModel = "geminimulti" | "edge" | "openai" | "elevenlabs"; 60 | 61 | interface PodcastPayload { 62 | urls: string[]; 63 | name: string; 64 | tagline: string; 65 | is_long_form: boolean; 66 | creativity: number; 67 | conversation_style: string[]; 68 | roles_person1: string; 69 | roles_person2: string; 70 | dialogue_structure: string[]; 71 | user_instructions?: string; 72 | engagement_techniques: string[]; 73 | tts_model: TTSModel; 74 | google_key?: string; 75 | openai_key?: string; 76 | elevenlabs_key?: string; 77 | image_urls?: string[]; 78 | } 79 | 80 | const formSchema = z.object({ 81 | urls: z.string(), 82 | podcastName: z.string(), 83 | podcastTagline: z.string(), 84 | instructions: z.string(), 85 | isLongForm: z.boolean().default(false), 86 | creativityLevel: z.number().min(0).max(1), 87 | interviewerRole: z.string(), 88 | expertRole: z.string(), 89 | conversationStyles: z.array(z.string()), 90 | dialogueStructure: z.array(z.string()), 91 | engagementTechniques: z.array(z.string()), 92 | ttsModel: z.string(), 93 | imageUrls: z.string(), 94 | }); 95 | 96 | const extractUrls = (text: string): string[] => { 97 | const urlRegex = /(https?:\/\/[^\s]+)/g; 98 | return text.match(urlRegex) || []; 99 | }; 100 | 101 | const extractImageUrls = (text: string): string[] => { 102 | const imageExtensionRegex = /(https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp))/gi; 103 | const imageUrls = text.match(imageExtensionRegex) || []; 104 | 105 | const generalUrlRegex = /(https?:\/\/[^\s]+)/g; 106 | const allUrls = text.match(generalUrlRegex) || []; 107 | 108 | const additionalImageUrls = allUrls.filter((url) => { 109 | const lowercaseUrl = url.toLowerCase(); 110 | return ( 111 | lowercaseUrl.includes("images") || 112 | lowercaseUrl.includes("img") || 113 | lowercaseUrl.includes("photos") || 114 | lowercaseUrl.includes("imgur") || 115 | lowercaseUrl.includes("cloudinary") || 116 | lowercaseUrl.includes("imagekit") || 117 | lowercaseUrl.includes("uploadcare") || 118 | lowercaseUrl.includes("cdn") || 119 | lowercaseUrl.includes("media") || 120 | lowercaseUrl.includes("image=") || 121 | lowercaseUrl.includes("type=image") 122 | ); 123 | }); 124 | 125 | return [...new Set([...imageUrls, ...additionalImageUrls])]; 126 | }; 127 | 128 | type PodcastFormData = z.infer; 129 | 130 | const AddCustomValue = ({ 131 | onAdd, 132 | placeholder, 133 | }: { 134 | onAdd: (value: string) => void; 135 | placeholder: string; 136 | }) => { 137 | const [isAdding, setIsAdding] = useState(false); 138 | const [value, setValue] = useState(""); 139 | const inputRef = useRef(null); 140 | 141 | useEffect(() => { 142 | if (isAdding) { 143 | inputRef.current?.focus(); 144 | } 145 | }, [isAdding]); 146 | 147 | const handleAdd = () => { 148 | if (value.trim()) { 149 | onAdd(value.trim()); 150 | setValue(""); 151 | setIsAdding(false); 152 | } 153 | }; 154 | 155 | return isAdding ? ( 156 |
157 | setValue(e.target.value)} 161 | placeholder={placeholder} 162 | className="h-8 text-sm" 163 | onKeyDown={(e) => { 164 | if (e.key === "Enter") handleAdd(); 165 | if (e.key === "Escape") setIsAdding(false); 166 | }} 167 | /> 168 | 176 |
177 | ) : ( 178 | setIsAdding(true)} 182 | > 183 | + Add Custom 184 | 185 | ); 186 | }; 187 | 188 | export function CustomPodcast() { 189 | const [parsedUrls, setParsedUrls] = useState([]); 190 | const [progress, setProgress] = useState(0); 191 | const [isGenerating, setIsGenerating] = useState(false); 192 | const [statusMessage, setStatusMessage] = useState(""); 193 | const [audioUrl, setAudioUrl] = useState(""); 194 | const [transcript, setTranscript] = useState(""); 195 | const { toast } = useToast(); 196 | const [parsedImageUrls, setParsedImageUrls] = useState([]); 197 | 198 | const form = useForm>({ 199 | resolver: zodResolver(formSchema), 200 | defaultValues: { 201 | urls: "", 202 | podcastName: "", 203 | podcastTagline: "", 204 | instructions: "", 205 | isLongForm: false, 206 | creativityLevel: 0.7, 207 | interviewerRole: "Interviewer", 208 | expertRole: "Subject matter expert", 209 | conversationStyles: ["Engaging", "Fast-paced", "Enthusiastic"], 210 | dialogueStructure: ["Discussions"], 211 | engagementTechniques: ["Questions"], 212 | ttsModel: "geminimulti", 213 | imageUrls: "", 214 | }, 215 | }); 216 | 217 | useEffect(() => { 218 | const savedData = localStorage.getItem("podcast_form"); 219 | const savedUrls = localStorage.getItem("podcast_urls"); 220 | const savedImageUrls = localStorage.getItem("podcast_image_urls"); 221 | 222 | if (savedData) { 223 | const parsedData = JSON.parse(savedData) as PodcastFormData; 224 | Object.entries(parsedData).forEach(([key, value]) => { 225 | form.setValue(key as keyof PodcastFormData, value); 226 | }); 227 | } 228 | 229 | if (savedUrls) { 230 | setParsedUrls(JSON.parse(savedUrls)); 231 | } 232 | 233 | if (savedImageUrls) { 234 | setParsedImageUrls(JSON.parse(savedImageUrls)); 235 | } 236 | }, []); 237 | 238 | useEffect(() => { 239 | const formData = form.getValues(); 240 | localStorage.setItem("podcast_form", JSON.stringify(formData)); 241 | }, [form.watch()]); 242 | 243 | useEffect(() => { 244 | localStorage.setItem("podcast_urls", JSON.stringify(parsedUrls)); 245 | }, [parsedUrls]); 246 | 247 | useEffect(() => { 248 | localStorage.setItem("podcast_image_urls", JSON.stringify(parsedImageUrls)); 249 | }, [parsedImageUrls]); 250 | 251 | const handleUrlInput = (text: string) => { 252 | const urls = extractUrls(text); 253 | if (urls.length > 0) { 254 | setParsedUrls((prev) => [...new Set([...prev, ...urls])]); 255 | form.setValue("urls", "", { shouldValidate: true }); 256 | toast({ 257 | title: "URLs Extracted", 258 | description: `Successfully extracted ${urls.length} URLs`, 259 | }); 260 | } 261 | }; 262 | 263 | const handleImageUrlInput = (text: string) => { 264 | const urls = extractImageUrls(text); 265 | if (urls.length > 0) { 266 | setParsedImageUrls((prev) => [...new Set([...prev, ...urls])]); 267 | form.setValue("imageUrls", "", { shouldValidate: true }); 268 | toast({ 269 | title: "Image URLs Extracted", 270 | description: `Successfully extracted ${urls.length} image URLs`, 271 | }); 272 | } else { 273 | const regularUrls = extractUrls(text); 274 | if (regularUrls.length > 0) { 275 | const imageUrls = regularUrls.filter((url) => 276 | /\.(jpg|jpeg|png|gif|webp)$/i.test(url) 277 | ); 278 | if (imageUrls.length > 0) { 279 | setParsedImageUrls((prev) => [...new Set([...prev, ...imageUrls])]); 280 | form.setValue("imageUrls", "", { shouldValidate: true }); 281 | toast({ 282 | title: "Image URLs Extracted", 283 | description: `Successfully extracted ${imageUrls.length} image URLs`, 284 | }); 285 | } 286 | } 287 | } 288 | }; 289 | 290 | const onPaste = (e: React.ClipboardEvent) => { 291 | e.preventDefault(); 292 | const pastedText = e.clipboardData.getData("text"); 293 | handleUrlInput(pastedText); 294 | }; 295 | 296 | const onKeyDown = (e: React.KeyboardEvent) => { 297 | if (e.key === "Enter" && !e.shiftKey) { 298 | e.preventDefault(); 299 | const inputText = form.getValues("urls"); 300 | handleUrlInput(inputText); 301 | } 302 | }; 303 | 304 | const removeUrl = (index: number) => { 305 | // Prevent event propagation 306 | event?.preventDefault(); 307 | event?.stopPropagation(); 308 | 309 | setParsedUrls((prev) => prev.filter((_, i) => i !== index)); 310 | }; 311 | 312 | const getRequiredApiKey = (model: TTSModel) => { 313 | switch (model) { 314 | case "geminimulti": 315 | return { key: sessionStorage.getItem("google_key"), name: "Google" }; 316 | case "openai": 317 | return { key: sessionStorage.getItem("openai_key"), name: "OpenAI" }; 318 | case "elevenlabs": 319 | return { 320 | key: sessionStorage.getItem("elevenlabs_key"), 321 | name: "ElevenLabs", 322 | }; 323 | case "edge": 324 | return null; // No API key required 325 | } 326 | }; 327 | 328 | const onSubmit = async (values: z.infer) => { 329 | try { 330 | // Validate URLs first 331 | if (parsedUrls.length === 0) { 332 | toast({ 333 | title: "No URLs", 334 | description: "Please add at least one URL to generate the podcast", 335 | variant: "destructive", 336 | }); 337 | return; 338 | } 339 | 340 | setIsGenerating(true); 341 | setProgress(0); 342 | setStatusMessage("Connecting to server..."); 343 | setAudioUrl(""); // Clear previous audio 344 | setTranscript(""); // Clear previous transcript 345 | 346 | // Create socket connection 347 | const socket = io({ 348 | path: "/socket.io", 349 | reconnection: true, 350 | timeout: 10000, 351 | }); 352 | 353 | // Handle cleanup 354 | const cleanup = () => { 355 | console.log("Cleaning up socket connection..."); 356 | socket.disconnect(); 357 | setIsGenerating(false); 358 | }; 359 | 360 | // Add event handlers 361 | socket.on("connect", () => { 362 | console.log("Socket connected successfully"); 363 | setStatusMessage("Connected to server"); 364 | 365 | const payload: PodcastPayload = { 366 | urls: parsedUrls, 367 | name: values.podcastName, 368 | tagline: values.podcastTagline, 369 | is_long_form: values.isLongForm, 370 | creativity: values.creativityLevel, 371 | conversation_style: values.conversationStyles, 372 | roles_person1: values.interviewerRole, 373 | roles_person2: values.expertRole, 374 | dialogue_structure: values.dialogueStructure, 375 | user_instructions: values.instructions, 376 | engagement_techniques: values.engagementTechniques, 377 | tts_model: values.ttsModel as TTSModel, 378 | image_urls: parsedImageUrls.length > 0 ? parsedImageUrls : undefined, 379 | }; 380 | 381 | // Add API key based on selected model 382 | const requiredApiKey = getRequiredApiKey(values.ttsModel as TTSModel); 383 | if (requiredApiKey) { 384 | switch (values.ttsModel) { 385 | case "geminimulti": 386 | payload.google_key = requiredApiKey.key || undefined; 387 | break; 388 | case "openai": 389 | payload.openai_key = requiredApiKey.key || undefined; 390 | break; 391 | case "elevenlabs": 392 | payload.elevenlabs_key = requiredApiKey.key || undefined; 393 | break; 394 | } 395 | } 396 | 397 | socket.emit("generate_podcast", payload); 398 | }); 399 | 400 | socket.on("progress", (data: { progress: number; message: string }) => { 401 | setProgress(data.progress); 402 | setStatusMessage(data.message); 403 | }); 404 | 405 | socket.on("status", (message: string) => { 406 | setStatusMessage(message); 407 | }); 408 | 409 | socket.on("error", (error: { message: string }) => { 410 | toast({ 411 | title: "Error", 412 | description: error.message, 413 | variant: "destructive", 414 | }); 415 | cleanup(); 416 | }); 417 | 418 | socket.on("disconnect", () => { 419 | console.log("Socket disconnected"); 420 | cleanup(); 421 | }); 422 | 423 | socket.on( 424 | "complete", 425 | (data: { audioUrl: string; transcript: string }) => { 426 | console.log("Podcast generation complete:", data); 427 | setAudioUrl(data.audioUrl); 428 | setTranscript(data.transcript); 429 | cleanup(); 430 | } 431 | ); 432 | 433 | // Handle component unmount 434 | return () => cleanup(); 435 | } catch (error: any) { 436 | console.error("Error in onSubmit:", error); 437 | setIsGenerating(false); 438 | toast({ 439 | title: "Error", 440 | description: error.message || "An error occurred", 441 | variant: "destructive", 442 | }); 443 | } 444 | }; 445 | 446 | const conversationStyles: ConversationStyle[] = [ 447 | "Engaging", 448 | "Fast-paced", 449 | "Enthusiastic", 450 | "Educational", 451 | "Casual", 452 | "Professional", 453 | "Friendly", 454 | ]; 455 | const dialogueStructures: DialogueStructure[] = [ 456 | "Topic Introduction", 457 | "Summary", 458 | "Discussions", 459 | "Q&A", 460 | "Farewell", 461 | ]; 462 | const engagementTechniques: EngagementTechnique[] = [ 463 | "Questions", 464 | "Testimonials", 465 | "Quotes", 466 | "Anecdotes", 467 | "Analogies", 468 | "Humor", 469 | ]; 470 | 471 | const clearSavedData = () => { 472 | localStorage.removeItem("podcast_form"); 473 | localStorage.removeItem("podcast_urls"); 474 | localStorage.removeItem("podcast_image_urls"); 475 | form.reset(); 476 | setParsedUrls([]); 477 | setParsedImageUrls([]); 478 | toast({ 479 | title: "Form Cleared", 480 | description: "All saved data has been cleared", 481 | }); 482 | }; 483 | 484 | // Convert the constant arrays to state so we can add to them 485 | const [customConversationStyles, setCustomConversationStyles] = 486 | useState(conversationStyles); 487 | const [customDialogueStructures, setCustomDialogueStructures] = 488 | useState(dialogueStructures); 489 | const [customEngagementTechniques, setCustomEngagementTechniques] = 490 | useState(engagementTechniques); 491 | 492 | const onImagePaste = (e: React.ClipboardEvent) => { 493 | e.preventDefault(); 494 | const pastedText = e.clipboardData.getData("text"); 495 | handleImageUrlInput(pastedText); 496 | }; 497 | 498 | const onImageKeyDown = (e: React.KeyboardEvent) => { 499 | if (e.key === "Enter" && !e.shiftKey) { 500 | e.preventDefault(); 501 | const inputText = form.getValues("imageUrls"); 502 | handleImageUrlInput(inputText); 503 | } 504 | }; 505 | 506 | const removeImageUrl = (index: number) => { 507 | event?.preventDefault(); 508 | event?.stopPropagation(); 509 | setParsedImageUrls((prev) => prev.filter((_, i) => i !== index)); 510 | }; 511 | 512 | return ( 513 |
514 | 515 |
516 |

Custom Podcast

517 | 525 |
526 | 527 |
528 |
529 | 530 |