From 00e9eb89860752acf0a4d95353822b89c7a0e915 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Mon, 22 Dec 2025 14:47:25 -0500 Subject: [PATCH] initial --- .dockerignore | 57 + .env.example | 20 + .python-version | 1 + API_EXAMPLES.md | 233 ++ AUTHELIA_SETUP.md | 183 + CLAUDE.md | 214 + Dockerfile.backend | 28 + Dockerfile.frontend | 18 + MIGRATION_GUIDE.md | 43 + README.md | Bin 0 -> 8548 bytes YOUTUBE_AUDIO_IMPLEMENTATION_STATUS.md | 303 ++ backend/__init__.py | 0 backend/app.py | 89 + backend/auth/__init__.py | 25 + backend/auth/middleware.py | 131 + backend/celery_app.py | 29 + backend/config.py | 84 + backend/models.py | 278 ++ backend/routes/__init__.py | 0 backend/routes/admin.py | 304 ++ backend/routes/auth.py | 124 + backend/routes/categories.py | 91 + backend/routes/download_jobs.py | 18 + backend/routes/games.py | 183 + backend/routes/questions.py | 343 ++ backend/routes/teams.py | 20 + backend/services/__init__.py | 0 backend/services/audio_service.py | 39 + backend/services/game_service.py | 285 ++ backend/services/image_service.py | 78 + backend/services/youtube_service.py | 71 + backend/sockets/__init__.py | 0 backend/sockets/events.py | 114 + backend/static/images/.gitkeep | 0 backend/tasks/__init__.py | 1 + backend/tasks/youtube_tasks.py | 120 + docker-compose.yml | 125 + frontend/.gitignore | 24 + frontend/README.md | 16 + frontend/frontend/biome.json | 231 ++ frontend/frontend/eslint.config.js | 29 + frontend/frontend/index.html | 17 + frontend/frontend/package-lock.json | 3533 +++++++++++++++++ frontend/frontend/package.json | 33 + frontend/frontend/public/vite.svg | 1 + frontend/frontend/src/App.css | 43 + frontend/frontend/src/App.jsx | 143 + frontend/frontend/src/assets/react.svg | 1 + .../src/components/admin/GameAdminView.jsx | 802 ++++ .../src/components/audio/AudioPlayer.jsx | 247 ++ .../src/components/auth/ProtectedRoute.jsx | 47 + .../categories/CategoryManagementView.jsx | 229 ++ .../src/components/common/AdminNavbar.jsx | 116 + .../components/contestant/ContestantView.jsx | 508 +++ .../components/questionbank/GameSetupView.jsx | 741 ++++ .../questionbank/QuestionBankView.jsx | 922 +++++ .../components/templates/TemplatesView.jsx | 139 + .../frontend/src/contexts/AuthContext.jsx | 159 + frontend/frontend/src/hooks/useSocket.js | 81 + frontend/frontend/src/index.css | 69 + frontend/frontend/src/main.jsx | 10 + frontend/frontend/src/pages/AuthCallback.jsx | 72 + frontend/frontend/src/pages/Login.jsx | 70 + frontend/frontend/src/services/api.js | 136 + frontend/frontend/vite.config.js | 33 + import_taylor_swift.py | 146 + import_taylor_swift_2.py | 98 + main.py | 20 + migrations/README | 1 + migrations/alembic.ini | 50 + migrations/env.py | 113 + migrations/script.py.mako | 24 + .../01a0ed14f3eb_add_category_model.py | 34 + ...a2589_add_phone_a_friend_count_to_teams.py | 32 + ...8b5_add_category_and_is_template_fields.py | 38 + ..._add_user_model_for_oidc_authentication.py | 41 + .../90b81e097444_make_user_email_nullable.py | 36 + ...e106a_initial_migration_with_all_models.py | 78 + .../fc2ec8c4e929_add_youtube_audio_support.py | 60 + pyproject.toml | 31 + uv.lock | 1027 +++++ 81 files changed, 13933 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .python-version create mode 100644 API_EXAMPLES.md create mode 100644 AUTHELIA_SETUP.md create mode 100644 CLAUDE.md create mode 100644 Dockerfile.backend create mode 100644 Dockerfile.frontend create mode 100644 MIGRATION_GUIDE.md create mode 100644 README.md create mode 100644 YOUTUBE_AUDIO_IMPLEMENTATION_STATUS.md create mode 100644 backend/__init__.py create mode 100644 backend/app.py create mode 100644 backend/auth/__init__.py create mode 100644 backend/auth/middleware.py create mode 100644 backend/celery_app.py create mode 100644 backend/config.py create mode 100644 backend/models.py create mode 100644 backend/routes/__init__.py create mode 100644 backend/routes/admin.py create mode 100644 backend/routes/auth.py create mode 100644 backend/routes/categories.py create mode 100644 backend/routes/download_jobs.py create mode 100644 backend/routes/games.py create mode 100644 backend/routes/questions.py create mode 100644 backend/routes/teams.py create mode 100644 backend/services/__init__.py create mode 100644 backend/services/audio_service.py create mode 100644 backend/services/game_service.py create mode 100644 backend/services/image_service.py create mode 100644 backend/services/youtube_service.py create mode 100644 backend/sockets/__init__.py create mode 100644 backend/sockets/events.py create mode 100644 backend/static/images/.gitkeep create mode 100644 backend/tasks/__init__.py create mode 100644 backend/tasks/youtube_tasks.py create mode 100644 docker-compose.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/frontend/biome.json create mode 100644 frontend/frontend/eslint.config.js create mode 100644 frontend/frontend/index.html create mode 100644 frontend/frontend/package-lock.json create mode 100644 frontend/frontend/package.json create mode 100644 frontend/frontend/public/vite.svg create mode 100644 frontend/frontend/src/App.css create mode 100644 frontend/frontend/src/App.jsx create mode 100644 frontend/frontend/src/assets/react.svg create mode 100644 frontend/frontend/src/components/admin/GameAdminView.jsx create mode 100644 frontend/frontend/src/components/audio/AudioPlayer.jsx create mode 100644 frontend/frontend/src/components/auth/ProtectedRoute.jsx create mode 100644 frontend/frontend/src/components/categories/CategoryManagementView.jsx create mode 100644 frontend/frontend/src/components/common/AdminNavbar.jsx create mode 100644 frontend/frontend/src/components/contestant/ContestantView.jsx create mode 100644 frontend/frontend/src/components/questionbank/GameSetupView.jsx create mode 100644 frontend/frontend/src/components/questionbank/QuestionBankView.jsx create mode 100644 frontend/frontend/src/components/templates/TemplatesView.jsx create mode 100644 frontend/frontend/src/contexts/AuthContext.jsx create mode 100644 frontend/frontend/src/hooks/useSocket.js create mode 100644 frontend/frontend/src/index.css create mode 100644 frontend/frontend/src/main.jsx create mode 100644 frontend/frontend/src/pages/AuthCallback.jsx create mode 100644 frontend/frontend/src/pages/Login.jsx create mode 100644 frontend/frontend/src/services/api.js create mode 100644 frontend/frontend/vite.config.js create mode 100644 import_taylor_swift.py create mode 100644 import_taylor_swift_2.py create mode 100644 main.py create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/01a0ed14f3eb_add_category_model.py create mode 100644 migrations/versions/1252454a2589_add_phone_a_friend_count_to_teams.py create mode 100644 migrations/versions/6347615a68b5_add_category_and_is_template_fields.py create mode 100644 migrations/versions/6d457de4e2b1_add_user_model_for_oidc_authentication.py create mode 100644 migrations/versions/90b81e097444_make_user_email_nullable.py create mode 100644 migrations/versions/c949c8fe106a_initial_migration_with_all_models.py create mode 100644 migrations/versions/fc2ec8c4e929_add_youtube_audio_support.py create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ad80218 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,57 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +.venv +.python-version +*.egg-info +dist +build + +# Node +node_modules +npm-debug.log +yarn-error.log + +# IDEs +.vscode +.idea +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local + +# Database (will be persisted via volumes) +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log + +# Docker +Dockerfile* +docker-compose*.yml +.dockerignore + +# Documentation +README.md +CLAUDE.md + +# Frontend build output (not needed during image build) +frontend/frontend/dist +backend/static/index.html +backend/static/assets diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2b5f671 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Authelia OIDC Configuration +# REQUIRED: Set these values to match your Authelia instance + +# Authelia issuer URL (e.g., https://auth.example.com) +OIDC_ISSUER= + +# OIDC client ID (must match the client configured in Authelia) +OIDC_CLIENT_ID=trivia-app + +# OIDC client secret (generated when configuring the client in Authelia) +OIDC_CLIENT_SECRET= + +# OAuth redirect URI (adjust if deploying to production) +OIDC_REDIRECT_URI=http://localhost:5001/api/auth/callback + +# Frontend URL for callbacks +FRONTEND_URL=http://localhost:3000 + +# Cookie security (set to 'true' in production with HTTPS) +SESSION_COOKIE_SECURE=false diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/API_EXAMPLES.md b/API_EXAMPLES.md new file mode 100644 index 0000000..9da0418 --- /dev/null +++ b/API_EXAMPLES.md @@ -0,0 +1,233 @@ +# API Examples for Bulk Operations + +## Creating Categories + +Categories can be created using the existing categories API: + +### Create a Single Category + +```bash +curl -X POST http://localhost:5000/api/categories \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Science" + }' +``` + +### Create Multiple Categories + +```bash +# History +curl -X POST http://localhost:5000/api/categories \ + -H "Content-Type: application/json" \ + -d '{"name": "History"}' + +# Geography +curl -X POST http://localhost:5000/api/categories \ + -H "Content-Type: application/json" \ + -d '{"name": "Geography"}' + +# Sports +curl -X POST http://localhost:5000/api/categories \ + -H "Content-Type: application/json" \ + -d '{"name": "Sports"}' +``` + +## Bulk Creating Questions + +Use the new bulk import endpoint to create many questions at once: + +```bash +curl -X POST http://localhost:5000/api/questions/bulk \ + -H "Content-Type: application/json" \ + -d '{ + "questions": [ + { + "question_content": "What is the capital of France?", + "answer": "Paris", + "category": "Geography", + "type": "text" + }, + { + "question_content": "Who painted the Mona Lisa?", + "answer": "Leonardo da Vinci", + "category": "Art", + "type": "text" + }, + { + "question_content": "What year did World War II end?", + "answer": "1945", + "category": "History", + "type": "text" + }, + { + "question_content": "What is the chemical symbol for gold?", + "answer": "Au", + "category": "Science", + "type": "text" + }, + { + "question_content": "How many players are on a soccer team?", + "answer": "11", + "category": "Sports", + "type": "text" + } + ] + }' +``` + +## Python Script Example + +```python +import requests + +API_BASE_URL = "http://localhost:5000/api" + +# Create categories +categories = ["Science", "History", "Geography", "Sports", "Entertainment", "Art"] +for category in categories: + response = requests.post( + f"{API_BASE_URL}/categories", + json={"name": category} + ) + print(f"Created category: {category}") + +# Bulk create questions +questions = [ + { + "question_content": "What is the capital of France?", + "answer": "Paris", + "category": "Geography", + "type": "text" + }, + { + "question_content": "Who painted the Mona Lisa?", + "answer": "Leonardo da Vinci", + "category": "Art", + "type": "text" + }, + { + "question_content": "What year did World War II end?", + "answer": "1945", + "category": "History", + "type": "text" + }, + { + "question_content": "What is H2O?", + "answer": "Water", + "category": "Science", + "type": "text" + }, + { + "question_content": "How many rings are in the Olympic logo?", + "answer": "5", + "category": "Sports", + "type": "text" + } +] + +response = requests.post( + f"{API_BASE_URL}/questions/bulk", + json={"questions": questions} +) + +result = response.json() +print(f"Created {result['created']} questions") +if result['errors']: + print(f"Errors: {result['errors']}") +``` + +## JavaScript/Node.js Example + +```javascript +const API_BASE_URL = "http://localhost:5000/api"; + +// Create categories +const categories = ["Science", "History", "Geography", "Sports", "Entertainment", "Art"]; +for (const category of categories) { + await fetch(`${API_BASE_URL}/categories`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: category }) + }); + console.log(`Created category: ${category}`); +} + +// Bulk create questions +const questions = [ + { + question_content: "What is the capital of France?", + answer: "Paris", + category: "Geography", + type: "text" + }, + { + question_content: "Who painted the Mona Lisa?", + answer: "Leonardo da Vinci", + category: "Art", + type: "text" + }, + // ... more questions +]; + +const response = await fetch(`${API_BASE_URL}/questions/bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ questions }) +}); + +const result = await response.json(); +console.log(`Created ${result.created} questions`); +``` + +## Response Format + +### Bulk Create Success Response + +```json +{ + "message": "Successfully created 5 questions", + "created": 5, + "errors": [], + "questions": [ + { + "id": 1, + "type": "text", + "question_content": "What is the capital of France?", + "answer": "Paris", + "category": "Geography", + "image_path": null, + "created_at": "2024-12-08T12:00:00" + } + // ... more questions + ] +} +``` + +### With Errors + +```json +{ + "message": "Successfully created 3 questions", + "created": 3, + "errors": [ + { + "index": 1, + "error": "question_content is required" + }, + { + "index": 4, + "error": "answer is required" + } + ], + "questions": [...] +} +``` + +## Notes + +- The bulk import endpoint does **not** support image questions +- Categories must be created before using them in questions (or use existing ones) +- The `type` field defaults to "text" if not specified +- The `category` field is optional +- Invalid questions in the bulk request will be skipped, and their errors will be reported diff --git a/AUTHELIA_SETUP.md b/AUTHELIA_SETUP.md new file mode 100644 index 0000000..d9052c2 --- /dev/null +++ b/AUTHELIA_SETUP.md @@ -0,0 +1,183 @@ +# Authelia OIDC Setup Guide + +This guide will help you configure Authelia OIDC authentication for the Trivia Game application. + +## Prerequisites + +- An existing Authelia instance running and accessible +- Access to Authelia's configuration file (`configuration.yml`) +- Users and groups configured in Authelia + +## Step 1: Configure Authelia Client + +Add the following client configuration to your Authelia `configuration.yml`: + +```yaml +identity_providers: + oidc: + clients: + - id: trivia-app + description: Trivia Game Application + secret: # Generate using: authelia crypto hash generate --password 'your-secret-here' + redirect_uris: + - http://localhost:3000/auth/callback + - http://localhost:5001/api/auth/callback + # Add production URLs when deploying: + # - https://trivia.example.com/auth/callback + # - https://trivia-api.example.com/api/auth/callback + scopes: + - openid + - email + - profile + grant_types: + - authorization_code + - refresh_token + response_types: + - code + token_endpoint_auth_method: client_secret_basic +``` + +**Generate the hashed secret:** +```bash +authelia crypto hash generate --password 'your-random-secret-here' +``` + +Save the **plaintext secret** (not the hash) for the next step. + +## Step 2: Configure Environment Variables + +Copy `.env.example` to `.env`: +```bash +cp .env.example .env +``` + +Edit `.env` with your Authelia details: +```bash +# Replace with your actual Authelia URL +OIDC_ISSUER=https://auth.example.com + +# Must match the client ID in Authelia config +OIDC_CLIENT_ID=trivia-app + +# Use the PLAINTEXT secret (not the hashed one) +OIDC_CLIENT_SECRET=your-random-secret-here + +# Adjust for production deployment +OIDC_REDIRECT_URI=http://localhost:5001/api/auth/callback +FRONTEND_URL=http://localhost:3000 + +# Set to 'true' in production with HTTPS +SESSION_COOKIE_SECURE=false +``` + +## Step 3: Restart Authelia + +After updating Authelia's configuration: +```bash +# Restart Authelia to apply the new client configuration +systemctl restart authelia +# or +docker restart authelia +``` + +## Step 4: Start the Trivia Application + +```bash +# Start with Docker Compose +docker compose up + +# Or start backend and frontend separately +PORT=5001 uv run python main.py # Backend +cd frontend/frontend && npm run dev # Frontend +``` + +## Step 5: Test Authentication + +1. Navigate to `http://localhost:3000` +2. You should be redirected to the login page +3. Click "Login with Authelia" +4. You'll be redirected to your Authelia instance +5. Log in with an Authelia user +6. After successful login, you'll be redirected back to the trivia app + +**All authenticated users** can: +- Access all routes (question management, game setup, admin controls) +- Create/edit questions +- Start/control games +- View contestant screens + +## Troubleshooting + +### "OAuth authentication disabled" warning +- **Cause**: `OIDC_ISSUER` is not set +- **Solution**: Ensure your `.env` file exists and has `OIDC_ISSUER` configured + +### "Invalid or expired token" errors +- **Cause**: JWT validation failing +- **Solutions**: + - Verify `OIDC_ISSUER` matches exactly (no trailing slash) + - Check Authelia logs for JWKS endpoint errors + - Ensure clocks are synchronized between Authelia and trivia app + +### Redirect loop on login +- **Cause**: Redirect URI mismatch +- **Solutions**: + - Ensure `OIDC_REDIRECT_URI` matches one of the `redirect_uris` in Authelia config + - Check that both frontend and backend URLs are correct + +### WebSocket connection fails +- **Cause**: JWT token not being sent or invalid +- **Solutions**: + - Check browser console for socket errors + - Verify user is authenticated before joining game + - Check backend logs for JWT validation errors + +## Production Deployment + +When deploying to production: + +1. Update Authelia client redirect URIs: +```yaml +redirect_uris: + - https://trivia.example.com/auth/callback + - https://trivia-api.example.com/api/auth/callback +``` + +2. Update environment variables: +```bash +OIDC_ISSUER=https://auth.example.com +OIDC_REDIRECT_URI=https://trivia-api.example.com/api/auth/callback +FRONTEND_URL=https://trivia.example.com +SESSION_COOKIE_SECURE=true # Important for HTTPS! +``` + +3. Ensure HTTPS is configured: +- Use a reverse proxy (nginx, Traefik, Caddy) +- Configure SSL certificates +- Update CORS origins to match production domains + +## Security Best Practices + +✅ **Do:** +- Use strong, random client secrets +- Enable `SESSION_COOKIE_SECURE=true` in production +- Use HTTPS for all production deployments +- Regularly rotate client secrets +- Monitor Authelia logs for suspicious activity + +❌ **Don't:** +- Commit `.env` file to version control +- Use default secrets in production +- Disable HTTPS in production +- Share client secrets publicly + +## Support + +For Authelia-specific issues, refer to: +- [Authelia Documentation](https://www.authelia.com/docs/) +- [Authelia OIDC Configuration](https://www.authelia.com/configuration/identity-providers/oidc/) + +For trivia app issues, check the backend logs: +```bash +docker compose logs backend -f +``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1a60005 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,214 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +A real-time trivia game web application with Flask backend and React frontend, featuring WebSocket support for live score updates and question displays. The app supports creating question banks, setting up games with teams, and running live trivia games with separate contestant (TV display) and admin control views. + +## Tech Stack + +**Backend:** +- Flask 3.0+ with Flask-SocketIO for WebSocket support +- SQLAlchemy ORM with Flask-Migrate for database migrations +- SQLite database (located at `backend/instance/trivia.db`) +- Eventlet for async/WebSocket server + +**Frontend:** +- React 19 with React Router 7 +- Vite for build tooling and dev server +- Socket.IO client for WebSocket communication +- Axios for HTTP requests +- **Note:** Frontend source is nested at `frontend/frontend/` (double directory structure) + +## Development Commands + +### Backend Setup and Running + +```bash +# Install Python dependencies +uv sync + +# Initialize database (first time only) +export FLASK_APP=backend.app:create_app +uv run flask db init + +# Create and apply migrations +uv run flask db migrate -m "Description of changes" +uv run flask db upgrade + +# Run backend server (use PORT=5001 to avoid macOS AirPlay conflict on 5000) +PORT=5001 uv run python main.py +``` + +### Frontend Setup and Running + +```bash +# Install frontend dependencies +cd frontend/frontend # Note the nested directory +npm install + +# Run frontend dev server (with proxy to backend) +npm run dev # Runs on port 3000 + +# Build for production (outputs to backend/static/) +npm run build +``` + +### Database Migrations + +When you modify models in `backend/models.py`: +```bash +export FLASK_APP=backend.app:create_app +uv run flask db migrate -m "Description of changes" +uv run flask db upgrade +``` + +### Docker Development (Recommended) + +Docker Compose provides the easiest development setup with automatic hot reload for both frontend and backend: + +```bash +# Start all services (backend + frontend with hot reload) +docker compose up + +# Start in detached mode +docker compose up -d + +# View logs +docker compose logs -f + +# Stop all services +docker compose down + +# Rebuild images after dependency changes +docker compose up --build + +# Run database migrations inside container +docker compose exec backend uv run flask db migrate -m "Description" +docker compose exec backend uv run flask db upgrade +``` + +**Services:** +- Backend: http://localhost:5001 (Flask + SocketIO) +- Frontend: http://localhost:3000 (Vite dev server) + +**Hot Reload:** +- Backend: Changes to `backend/`, `main.py`, and `migrations/` automatically reload Flask +- Frontend: Changes to `src/`, `public/`, and config files automatically reload Vite +- Database and uploaded images persist in volumes + +**Architecture:** +- `Dockerfile.backend`: Python 3.14 with uv, Flask in debug mode +- `Dockerfile.frontend`: Node 18 with Vite dev server +- `docker-compose.yml`: Orchestrates both services with volume mounts for hot reload +- Named volume for `node_modules` to avoid host/container conflicts +- Health check on backend before starting frontend + +## Architecture + +### Application Factory Pattern + +The Flask app uses an application factory (`backend/app.py:create_app()`) which: +- Initializes extensions (SQLAlchemy, Flask-Migrate, Flask-SocketIO, CORS) +- Registers blueprints for routes +- Serves React frontend in production from `backend/static/` +- Configured via `backend/config.py` with environment-based settings + +### Database Models (`backend/models.py`) + +Core entities: +- **Question**: Stores trivia questions (text or image type) with answers and optional categories +- **Game**: Represents a trivia game session with `is_active` flag and `current_question_index` +- **GameQuestion**: Junction table linking questions to games with ordering +- **Team**: Teams participating in a game +- **Score**: Tracks points awarded per team per question (composite unique constraint on team_id + question_index) +- **Category**: Optional categorization for questions + +Key relationships: +- Games can only be active one at a time (`Game.get_active()` class method) +- Teams calculate `total_score` as a property by summing all Score records +- Questions can be reused across multiple games via GameQuestion junction table + +### WebSocket Architecture (`backend/sockets/events.py`, `backend/services/game_service.py`) + +**Room-based design:** +- Each game has two rooms: `game_{id}_contestant` and `game_{id}_admin` +- Contestants join the contestant room and see questions WITHOUT answers +- Admin joins the admin room and sees questions WITH answers +- Score updates broadcast to both rooms + +**Key events:** +- `join_game` / `leave_game`: Client joins/leaves game rooms with role (contestant/admin) +- `game_started`: Emitted when game starts +- `question_changed`: Sent to contestant room (no answer) +- `question_with_answer`: Sent to admin room (with answer) +- `score_updated`: Broadcasts score changes to both rooms +- `answer_visibility_changed`: Toggles answer display on contestant screen + +**Game flow:** +- Admin starts game via `/api/admin/game//start` → broadcasts first question +- Admin navigates questions with `/next` and `/prev` → broadcasts to all clients +- Admin awards points via `/award` → creates/updates Score records → broadcasts to all +- Optional answer reveal to contestants via `/toggle-answer` + +### API Routes + +Routes are organized into blueprints: +- `backend/routes/questions.py`: Question CRUD (`/api/questions`) +- `backend/routes/games.py`: Game management (`/api/games`) +- `backend/routes/teams.py`: Team management (`/api/teams`) +- `backend/routes/admin.py`: Game control endpoints (`/api/admin/game//...`) +- `backend/routes/categories.py`: Category management (`/api/categories`) + +Admin endpoints use `game_service.py` functions which handle both database updates AND WebSocket broadcasts. + +### Frontend Build Configuration + +**Development:** +- Vite dev server runs on port 3000 +- Proxies `/api` and `/socket.io` requests to Flask backend on port 5001 +- Configuration in `frontend/frontend/vite.config.js` + +**Production:** +- `npm run build` outputs to `backend/static/` (configured via `build.outDir`) +- Flask serves the built React app and handles client-side routing +- All non-API routes return `index.html` for React Router + +### Image Handling + +- Images uploaded to `backend/static/images/` via `backend/services/image_service.py` +- Image questions store `image_path` in the Question model +- Images served as static files by Flask in production + +## Common Development Patterns + +### Adding a New Model Field + +1. Modify the model in `backend/models.py` +2. Update the model's `to_dict()` method if the field should be serialized +3. Create migration: `uv run flask db migrate -m "Add field_name to Model"` +4. Apply migration: `uv run flask db upgrade` + +### Adding a New WebSocket Event + +1. Define event handler in `backend/sockets/events.py` using `@socketio.on('event_name')` +2. Use `emit()` to send responses or `join_room()`/`leave_room()` for room management +3. To broadcast to specific rooms, use `emit('event', data, room='room_name')` +4. Update frontend Socket.IO hook to listen for the new event + +### Adding a New API Endpoint + +1. Add route function to appropriate blueprint in `backend/routes/` +2. Use `db.session.commit()` for database changes with try/except for rollback +3. Return JSON responses with appropriate status codes +4. For admin actions affecting game state, use functions from `game_service.py` to ensure WebSocket broadcasts + +## Important Notes + +- **Port 5000 conflict**: macOS AirPlay Receiver uses port 5000 by default. Use `PORT=5001` when running the backend. +- **Nested frontend directory**: Frontend source code is at `frontend/frontend/`, not `frontend/src/` +- **Single active game**: Only one game can be active at a time (enforced in `start_game()`) +- **Score uniqueness**: A team can only have one score per question (enforced by database constraint) +- **WebSocket server**: Must use `socketio.run(app)` not `app.run()` for WebSocket support (configured in `main.py`) +- **CORS**: Configured to allow all origins in development via `CORS_ORIGINS=*` diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..6041a0c --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,28 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install system dependencies (ffmpeg for pydub audio processing) +RUN apt-get update && apt-get install -y \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Copy dependency files +COPY pyproject.toml uv.lock ./ + +# Install dependencies +RUN uv sync --frozen + +# Copy application code +COPY backend ./backend +COPY main.py ./ +COPY migrations ./migrations + +# Expose port +EXPOSE 5001 + +# Run with hot reload enabled +CMD ["uv", "run", "python", "main.py"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..6b6ec2f --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,18 @@ +FROM node:20-slim + +WORKDIR /app + +# Copy package files from nested directory +COPY frontend/frontend/package*.json ./ + +# Install dependencies +RUN npm install + +# Copy application code +COPY frontend/frontend ./ + +# Expose Vite dev server port +EXPOSE 3000 + +# Run dev server with host 0.0.0.0 to allow access from outside container +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..d02650e --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,43 @@ +# Database Migration Guide + +After updating the code, run these commands to create and apply the database migration: + +```bash +# Build and start containers +docker compose up --build -d + +# Create migration +docker compose exec backend uv run flask db migrate -m "Add YouTube audio support and download job tracking" + +# Apply migration +docker compose exec backend uv run flask db upgrade + +# Verify services are running +docker compose ps + +# View logs +docker compose logs -f backend +docker compose logs -f celery-worker +``` + +## Services + +- **Backend**: http://localhost:5001 +- **Frontend**: http://localhost:3000 +- **Celery Flower (monitoring)**: http://localhost:5555 +- **Redis**: localhost:6379 + +## Troubleshooting + +If migration fails: +```bash +# Check if database is accessible +docker compose exec backend ls -la backend/instance/ + +# Reset if needed (WARNING: destroys data) +docker compose down -v +docker compose up --build -d +docker compose exec backend uv run flask db init +docker compose exec backend uv run flask db migrate -m "Initial migration" +docker compose exec backend uv run flask db upgrade +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..defb95f2928fb8cd00592842faa82f2f2c310644 GIT binary patch literal 8548 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 literal 0 HcmV?d00001 diff --git a/YOUTUBE_AUDIO_IMPLEMENTATION_STATUS.md b/YOUTUBE_AUDIO_IMPLEMENTATION_STATUS.md new file mode 100644 index 0000000..0d28050 --- /dev/null +++ b/YOUTUBE_AUDIO_IMPLEMENTATION_STATUS.md @@ -0,0 +1,303 @@ +# YouTube Audio Feature - Implementation Status + +## ✅ COMPLETED (Backend + Infrastructure) + +### Infrastructure (100%) +- ✅ Added Python dependencies: celery, redis, yt-dlp, pydub +- ✅ Updated Dockerfile.backend to install ffmpeg +- ✅ Added Redis, Celery worker, and Celery Flower services to docker-compose.yml +- ✅ Configured audio volume mounts in Docker + +### Configuration (100%) +- ✅ Added audio and Celery configuration to `backend/config.py` + - Audio folder, file size limits, allowed extensions + - Celery broker/backend URLs + - yt-dlp settings (format, quality) + +### Database Models (100%) +- ✅ Added `YOUTUBE_AUDIO` to `QuestionType` enum +- ✅ Added YouTube fields to Question model: + - `youtube_url` - YouTube video URL + - `audio_path` - Path to trimmed audio file + - `start_time` - Start time in seconds + - `end_time` - End time in seconds +- ✅ Created `DownloadJob` model to track async processing + - Tracks status (pending/processing/completed/failed) + - Progress (0-100%) + - Error messages + - Celery task ID + +### Backend Services (100%) +- ✅ Created `backend/celery_app.py` - Celery configuration +- ✅ Created `backend/services/audio_service.py` - Audio file utilities + - UUID-based filename generation + - File deletion cleanup +- ✅ Created `backend/services/youtube_service.py` - YouTube validation + - URL validation with regex + - Video duration fetching + - Timestamp range validation +- ✅ Created `backend/tasks/youtube_tasks.py` - Celery download task + - Downloads full audio via yt-dlp + - Trims to timestamp range using pydub + - Updates progress in database + - Handles errors and cleanup + +### API Routes (100%) +- ✅ Updated `backend/routes/questions.py`: + - Accepts YouTube audio question type + - Validates URL and timestamps + - Creates question with pending status + - Spawns Celery download task + - Returns 202 with job ID + - Deletes audio files on question deletion +- ✅ Created `backend/routes/download_jobs.py`: + - `GET /api/download-jobs/` - Get job status + - `GET /api/download-jobs/question/` - Get job by question +- ✅ Updated `backend/routes/admin.py` with audio control endpoints: + - `POST /api/admin/game//audio/play` - Broadcast play command + - `POST /api/admin/game//audio/pause` - Broadcast pause command + - `POST /api/admin/game//audio/stop` - Broadcast stop command + - `POST /api/admin/game//audio/seek` - Broadcast seek command + +### WebSocket Broadcasts (100%) +- ✅ Updated `backend/services/game_service.py` with audio broadcast functions: + - `broadcast_audio_play()` - Sends play event to contestant room + - `broadcast_audio_pause()` - Sends pause event + - `broadcast_audio_stop()` - Sends stop event + - `broadcast_audio_seek()` - Sends seek event with position + +### App Integration (100%) +- ✅ Updated `backend/app.py`: + - Creates audio folder on startup + - Registers download_jobs blueprint + +### Frontend API Client (100%) +- ✅ Updated `frontend/frontend/src/services/api.js`: + - Added `downloadJobsAPI` for job status polling + - Added `audioControlAPI` for playback controls + +--- + +## 🚧 REMAINING (Frontend UI Components) + +### 1. Question Creation Form +**File**: `frontend/frontend/src/components/questionbank/QuestionBankView.jsx` + +**Required Changes**: +- Add `"youtube_audio"` option to type dropdown +- Add form state for YouTube fields: + ```javascript + const [formData, setFormData] = useState({ + // ... existing fields + youtube_url: '', + start_time: 0, + end_time: 0 + }); + const [downloadJob, setDownloadJob] = useState(null); + const [downloadProgress, setDownloadProgress] = useState(0); + ``` +- Add conditional form fields when type === 'youtube_audio': + - YouTube URL input + - Start time number input (seconds) + - End time number input (seconds) + - Display calculated duration +- Update submit handler to POST YouTube data as JSON +- Implement polling logic: + ```javascript + const pollDownloadStatus = async (jobId) => { + const pollInterval = setInterval(async () => { + const response = await downloadJobsAPI.getStatus(jobId); + setDownloadProgress(response.data.progress); + if (response.data.status === 'completed') { + clearInterval(pollInterval); + // Reload questions, show success message + } + }, 2000); + }; + ``` +- Add download progress indicator UI (fixed position, bottom-right) + +### 2. Audio Player Component +**File**: `frontend/frontend/src/components/audio/AudioPlayer.jsx` (NEW) + +**Component Structure**: +```jsx +export default function AudioPlayer({ audioPath, isAdmin, socket, gameId }) { + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + + // Contestant mode: Listen to WebSocket events + useEffect(() => { + if (!isAdmin && socket) { + socket.on('audio_play', () => audioRef.current?.play()); + socket.on('audio_pause', () => audioRef.current?.pause()); + socket.on('audio_stop', () => { + audioRef.current?.pause(); + audioRef.current.currentTime = 0; + }); + socket.on('audio_seek', (data) => { + audioRef.current.currentTime = data.position; + }); + } + }, [socket, isAdmin]); + + // Admin controls + const handlePlay = async () => { + await audioControlAPI.play(gameId); + audioRef.current?.play(); + }; + // ... similar for pause, stop + + return ( +
+
+ ); +} +``` + +**Styling**: +- Progress bar: clickable (admin only), blue fill (#2196F3) +- Time display: MM:SS format +- Buttons: Play (green #4CAF50), Pause (orange #ff9800), Stop (red #f44336) + +### 3. Contestant View Update +**File**: `frontend/frontend/src/components/contestant/ContestantView.jsx` + +**Required Changes**: +```jsx +import AudioPlayer from '../audio/AudioPlayer'; + +// In render, after image block: +{currentQuestion.type === 'youtube_audio' && currentQuestion.audio_path && ( + +)} +``` + +### 4. Admin View Update +**File**: `frontend/frontend/src/components/admin/GameAdminView.jsx` + +**Required Changes**: +```jsx +import AudioPlayer from '../audio/AudioPlayer'; + +// In current question display section: +{currentQuestion?.type === 'youtube_audio' && currentQuestion?.audio_path && ( + +)} +``` + +--- + +## 📋 Testing Checklist + +### Backend Tests +- [ ] Start containers: `docker compose up --build` +- [ ] Create migration: `docker compose exec backend uv run flask db migrate -m "Add YouTube audio support"` +- [ ] Apply migration: `docker compose exec backend uv run flask db upgrade` +- [ ] Test invalid YouTube URL: Should return 400 error +- [ ] Test invalid timestamps: Should return validation error +- [ ] Test job status endpoint: `curl http://localhost:5001/api/download-jobs/1` + +### Frontend Tests (after UI completion) +- [ ] Create YouTube audio question + - Submit URL with timestamps + - Verify progress indicator appears + - Wait for completion alert + - Verify question appears in list +- [ ] Test audio playback + - Start game with YouTube question + - Open contestant view (tab 1) + - Open admin view (tab 2) + - Admin clicks Play → Contestant audio plays + - Admin clicks Pause → Contestant audio pauses + - Admin clicks Stop → Contestant audio resets +- [ ] Test question navigation + - Admin clicks Next → Audio stops + - Return to audio question → Audio ready to play again + +--- + +## 🎯 Next Steps + +1. **Complete frontend UI components** (4 files to update/create) +2. **Run database migration** (see MIGRATION_GUIDE.md) +3. **Test end-to-end workflow** +4. **Optional enhancements**: + - Add audio waveform visualization + - Add timestamp selector UI with embedded YouTube player + - Add video thumbnail preview + - Support multiple clips per question + +--- + +## 📁 Files Modified/Created + +### Backend (15 files) +- ✅ `pyproject.toml` +- ✅ `docker-compose.yml` +- ✅ `Dockerfile.backend` +- ✅ `backend/config.py` +- ✅ `backend/models.py` +- ✅ `backend/app.py` +- ✅ `backend/celery_app.py` (NEW) +- ✅ `backend/services/audio_service.py` (NEW) +- ✅ `backend/services/youtube_service.py` (NEW) +- ✅ `backend/tasks/__init__.py` (NEW) +- ✅ `backend/tasks/youtube_tasks.py` (NEW) +- ✅ `backend/routes/questions.py` +- ✅ `backend/routes/download_jobs.py` (NEW) +- ✅ `backend/routes/admin.py` +- ✅ `backend/services/game_service.py` + +### Frontend (4 files - 1 complete, 3 remaining) +- ✅ `frontend/frontend/src/services/api.js` +- 🚧 `frontend/frontend/src/components/questionbank/QuestionBankView.jsx` +- 🚧 `frontend/frontend/src/components/audio/AudioPlayer.jsx` (NEW) +- 🚧 `frontend/frontend/src/components/contestant/ContestantView.jsx` +- 🚧 `frontend/frontend/src/components/admin/GameAdminView.jsx` + +### Documentation +- ✅ `MIGRATION_GUIDE.md` (NEW) +- ✅ `YOUTUBE_AUDIO_IMPLEMENTATION_STATUS.md` (NEW) + +--- + +## 🔑 Key Implementation Details + +### Async Flow +1. User submits YouTube question → API creates Question with null `audio_path` +2. Celery task starts → Downloads full audio → Trims clip → Saves MP3 +3. Frontend polls job status every 2 seconds +4. On completion → Question's `audio_path` updated → Frontend shows success + +### Audio Playback Architecture +- **Admin**: Play/Pause/Stop buttons → API → WebSocket broadcast to contestants +- **Contestants**: Listen to WebSocket events → Control HTML5 `