commit 76b9485d2c2275ebce086455e151c64134614b4d Author: Jeff Emmett Date: Tue Nov 25 03:37:35 2025 -0800 Initial commit: OBS R2 video uploader and streaming service - Cloudflare Worker for video serving from R2 bucket - Admin panel with authentication and video management - OBS integration for automatic video uploads - HLS live streaming support with nginx-rtmp - KV namespace for video metadata (visibility settings) - Video gallery with thumbnails and playback - Support for multiple video formats (mp4, mkv, mov, etc.) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..caab59d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,30 @@ +.git +.gitignore +.env +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +venv +.venv +env +ENV +node_modules +worker/node_modules +tests +*.md +!README.md +recordings +*.mp4 +*.mkv +*.mov +*.avi +*.webm +*.flv +*.wmv diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3daab79 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Cloudflare R2 Configuration +R2_ACCOUNT_ID=your_account_id_here +R2_ACCESS_KEY_ID=your_access_key_here +R2_SECRET_ACCESS_KEY=your_secret_key_here +R2_BUCKET_NAME=obs-videos + +# R2 Endpoint (replace with your actual account ID) +R2_ENDPOINT=https://.r2.cloudflarestorage.com + +# Public Domain for video access +PUBLIC_DOMAIN=videos.jeffemmett.com + +# Optional: OBS Recording Directory (for file watcher) +OBS_RECORDING_DIR=/path/to/obs/recordings + +# Optional: Auto-delete local files after upload +AUTO_DELETE_AFTER_UPLOAD=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5cc1a01 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Environment variables +.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +*.egg-info/ +dist/ +build/ + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Wrangler +.wrangler/ +.dev.vars + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Test videos +*.mp4 +*.mkv +*.mov +*.avi +!tests/test-video.mp4 + +# Logs +*.log diff --git a/ADMIN.md b/ADMIN.md new file mode 100644 index 0000000..e8303b2 --- /dev/null +++ b/ADMIN.md @@ -0,0 +1,317 @@ +# Admin Panel - Video Visibility Management + +The admin panel allows you to control who can access your videos with three visibility levels: + +## Visibility Levels + +### 🔒 Private +- **Access**: Only you (admin) can view +- **Use case**: Unreleased content, drafts, personal recordings +- **Sharing**: Not shareable via any link + +### 🔗 Shareable +- **Access**: Anyone with the link can view the full video +- **Use case**: Public content, presentations, tutorials +- **Sharing**: Full video URL works for anyone + +### ✂️ Clip Shareable +- **Access**: Full video requires admin auth, but time-based clips can be shared +- **Use case**: Long recordings where you only want to share specific segments +- **Sharing**: Generate clip links with start/end times + +## Setup + +### 1. Create KV Namespace + +```bash +cd worker +wrangler kv:namespace create VIDEO_METADATA +``` + +This creates a Cloudflare KV storage for video metadata. + +### 2. Configure Wrangler + +Copy the enhanced configuration: + +```bash +cp wrangler-enhanced.toml wrangler.toml +``` + +Update the KV namespace ID in `wrangler.toml`: + +```toml +[[kv_namespaces]] +binding = "VIDEO_METADATA" +id = "YOUR_KV_NAMESPACE_ID" # From step 1 +``` + +### 3. Set Admin Password + +```bash +wrangler secret put ADMIN_PASSWORD +``` + +Enter a secure password when prompted. This will be your login password. + +### 4. Build and Deploy + +```bash +# Build the worker with embedded admin interface +python3 scripts/build-worker.py + +# Deploy +cd worker +wrangler deploy +``` + +## Usage + +### Accessing the Admin Panel + +1. Navigate to `https://videos.jeffemmett.com/admin` +2. Enter your admin password +3. You'll see all your videos with management controls + +### Managing Video Visibility + +For each video, you can: + +1. **Change Visibility** + - Click the dropdown to select: Private, Shareable, or Clip Shareable + - Changes are saved immediately + +2. **Copy Share Link** + - Click "Copy Link" to get the direct video URL + - For shareable videos, anyone with this link can watch + +3. **Create Video Clips** + - Click "Create Clip" to expand the clip generator + - Enter start time (e.g., `0:30` or `1:30`) + - Enter end time (e.g., `2:00`) + - Click "Generate Clip Link" + - Share the clip URL with others + +4. **Delete Videos** + - Click "Delete" to permanently remove a video + - Confirms before deletion + - Cannot be undone + +### Dashboard Statistics + +The admin panel shows: +- **Total Videos**: All videos in your R2 bucket +- **Private**: Videos only you can access +- **Shareable**: Videos accessible by anyone +- **Clip Shareable**: Videos where only clips are shareable + +### Search and Filter + +Use the search bar to quickly find videos by name. + +## Clip Generation + +### How Clips Work + +When you generate a clip link, it creates a URL like: + +``` +https://videos.jeffemmett.com/clip/video.mp4?start=0:30&end=2:00 +``` + +This creates a shareable link that: +- Starts playback at the specified time +- Shows only the requested segment +- Works even for "Clip Shareable" videos + +### Time Format + +Times can be specified as: +- Seconds: `30`, `90` +- Minutes:Seconds: `1:30`, `2:45` +- Hours:Minutes:Seconds: `1:30:00` + +### Use Cases for Clips + +- **Highlights**: Share best moments from long streams +- **Excerpts**: Pull specific segments from meetings +- **Teasers**: Create preview clips from full content +- **Context**: Share relevant portions without exposing full video + +## API Endpoints + +For automation, you can use these endpoints (requires authentication): + +### List All Videos +```bash +GET /admin/api/videos +``` + +Returns JSON with all videos and their metadata. + +### Update Video Visibility +```bash +POST /admin/api/videos/visibility +Content-Type: application/json + +{ + "filename": "video.mp4", + "visibility": "private" | "shareable" | "clip_shareable" +} +``` + +### Delete Video +```bash +DELETE /admin/api/videos/{filename} +``` + +### Authentication + +API requests require the `admin_auth` cookie. You can obtain this by logging in through the web interface. + +For programmatic access: + +```bash +# Login +curl -X POST https://videos.jeffemmett.com/admin/login \ + -H "Content-Type: application/json" \ + -d '{"password": "your_password"}' \ + -c cookies.txt + +# Use the cookie for subsequent requests +curl https://videos.jeffemmett.com/admin/api/videos \ + -b cookies.txt +``` + +## Security Considerations + +### Admin Password + +- Store your admin password securely +- Use a strong, unique password +- Rotate periodically using `wrangler secret put ADMIN_PASSWORD` + +### Session Management + +- Sessions expire after 24 hours +- HttpOnly cookies prevent JavaScript access +- Secure flag ensures HTTPS-only transmission + +### Video URLs + +- **Private videos**: URLs return 403 Forbidden without auth +- **Shareable videos**: Anyone with the URL can access (by design) +- **Clip shareable**: Full video protected, clips are public + +### Best Practices + +1. **Don't share admin credentials** +2. **Use Private for sensitive content** +3. **Review permissions regularly** +4. **Consider signed URLs** for temporary access (future feature) +5. **Monitor your R2 usage** in Cloudflare dashboard + +## Public vs Admin Gallery + +### Public Gallery +- URL: `https://videos.jeffemmett.com/gallery` +- Shows only "Shareable" videos +- No authentication required +- Great for sharing your portfolio + +### Admin Panel +- URL: `https://videos.jeffemmett.com/admin` +- Shows ALL videos regardless of visibility +- Requires authentication +- Full management controls + +## Troubleshooting + +### Can't login + +- Verify password: `wrangler secret put ADMIN_PASSWORD` +- Check browser cookies are enabled +- Try incognito mode to clear old sessions + +### Video not accessible after changing visibility + +- Changes are immediate, but CDN may cache +- Wait a few minutes for cache invalidation +- Use `?cache_bust=timestamp` in URL to bypass cache + +### Clips not working + +- Ensure video format supports seeking (MP4 recommended) +- Check that start/end times are valid +- Verify "Clip Shareable" is set correctly + +### KV namespace errors + +- Verify KV namespace ID in `wrangler.toml` +- Check KV namespace exists: `wrangler kv:namespace list` +- Ensure worker has KV binding configured + +## Advanced Usage + +### Bulk Operations + +You can script bulk visibility changes: + +```bash +# Set all videos to private +for video in $(curl -s https://videos.jeffemmett.com/admin/api/videos -b cookies.txt | jq -r '.videos[].name'); do + curl -X POST https://videos.jeffemmett.com/admin/api/videos/visibility \ + -b cookies.txt \ + -H "Content-Type: application/json" \ + -d "{\"filename\": \"$video\", \"visibility\": \"private\"}" +done +``` + +### Backup Metadata + +Export your visibility settings: + +```bash +curl https://videos.jeffemmett.com/admin/api/videos -b cookies.txt > backup.json +``` + +### Custom Domain for Admin + +You can set up a separate subdomain for admin: + +```toml +[env.production] +routes = [ + { pattern = "videos.jeffemmett.com/*", custom_domain = true }, + { pattern = "admin.jeffemmett.com/*", custom_domain = true } +] +``` + +Then route `/admin/*` paths to the admin subdomain. + +## Future Enhancements + +Potential features for future versions: + +- [ ] **Signed URLs**: Time-limited access for private videos +- [ ] **User roles**: Multiple admin levels +- [ ] **View analytics**: Track video views and downloads +- [ ] **Automatic transcoding**: Generate optimized clips server-side +- [ ] **Thumbnail management**: Custom thumbnails for each video +- [ ] **Batch uploads**: Upload multiple videos at once +- [ ] **Video editing**: Trim, merge, or modify videos in the admin panel +- [ ] **Access logs**: See who accessed which videos +- [ ] **Expiring links**: Set expiration dates for shareable links +- [ ] **Password-protected videos**: Per-video password protection + +## Support + +For issues or questions: +- Check the main [README.md](README.md) +- Review [SETUP.md](SETUP.md) for configuration help +- Check Cloudflare Worker logs: `wrangler tail` + +--- + +**Admin Panel Version:** 1.0 +**Last Updated:** 2024-11-22 diff --git a/ADMIN_QUICKSTART.md b/ADMIN_QUICKSTART.md new file mode 100644 index 0000000..91c466e --- /dev/null +++ b/ADMIN_QUICKSTART.md @@ -0,0 +1,139 @@ +# Admin Panel - Quick Start Guide + +Get your admin panel up and running in 5 minutes. + +## Prerequisites + +✅ Basic system is already set up (R2 bucket, worker deployed) +✅ Wrangler CLI is authenticated +✅ Python 3.8+ installed + +## Setup Steps + +### 1. Run Admin Setup Script + +```bash +./scripts/setup-admin.sh +``` + +This will: +- Create a KV namespace for video metadata +- Prompt you to set an admin password +- Update configuration automatically + +**Set a strong password!** This protects your admin panel. + +### 2. Build the Worker + +```bash +python3 scripts/build-worker.py +``` + +This embeds the admin interface into your Cloudflare Worker. + +### 3. Update Wrangler Config + +```bash +cd worker +cp wrangler-enhanced.toml wrangler.toml +``` + +Verify the KV namespace ID is correct in `wrangler.toml`: + +```toml +[[kv_namespaces]] +binding = "VIDEO_METADATA" +id = "abc123..." # Should be filled in automatically +``` + +### 4. Deploy + +```bash +wrangler deploy +``` + +### 5. Access Admin Panel + +Open: `https://videos.jeffemmett.com/admin` + +Login with the password you set in step 1. + +## Quick Usage + +### Set Video Visibility + +1. Find your video in the list +2. Click the dropdown +3. Choose: + - **Private** (🔒): Only you can access + - **Shareable** (🔗): Anyone with link + - **Clip Shareable** (✂️): Only clips are public + +### Create a Shareable Clip + +1. Click "Create Clip" on any video +2. Enter start time: `1:30` (1 minute 30 seconds) +3. Enter end time: `3:00` (3 minutes) +4. Click "Generate Clip Link" +5. Share the URL! + +### Delete a Video + +1. Click "Delete" button +2. Confirm deletion +3. Video is permanently removed from R2 + +## Troubleshooting + +### "Unauthorized" Error + +Reset your password: +```bash +cd worker +wrangler secret put ADMIN_PASSWORD +``` + +### KV Namespace Not Found + +List your namespaces: +```bash +wrangler kv:namespace list +``` + +Update the ID in `wrangler.toml`. + +### Admin Panel Shows Placeholder + +The admin HTML wasn't embedded. Run: +```bash +python3 scripts/build-worker.py +cd worker && wrangler deploy +``` + +## Default Behavior + +- New uploads are **Shareable** by default +- Public gallery shows only **Shareable** videos +- **Private** videos return 403 to non-admin users +- **Clip Shareable** videos require auth for full video + +## Security Tips + +✅ Use a strong, unique password +✅ Don't share your admin credentials +✅ Review video permissions regularly +✅ Sessions expire after 24 hours +✅ HTTPS-only cookies + +## Next Steps + +- Read [ADMIN.md](ADMIN.md) for complete documentation +- Learn about [API endpoints](ADMIN.md#api-endpoints) +- Explore [advanced features](ADMIN.md#advanced-usage) + +--- + +**Need Help?** +- Check [ADMIN.md](ADMIN.md) for detailed docs +- Review [SETUP.md](SETUP.md) for configuration +- Run `wrangler tail` to see worker logs diff --git a/DEPLOY_UPDATES.md b/DEPLOY_UPDATES.md new file mode 100644 index 0000000..dbf31a9 --- /dev/null +++ b/DEPLOY_UPDATES.md @@ -0,0 +1,191 @@ +# Deploy Admin Panel with Upload Feature + +Quick guide to deploy the enhanced admin panel with manual upload capability. + +## What Changed + +✅ **Admin Panel**: Added drag-and-drop video upload +✅ **Empty State**: Improved UI when no videos exist +✅ **Upload API**: New endpoint for browser-based uploads +✅ **Progress Tracking**: Real-time upload progress bars + +## Prerequisites + +- ✅ Worker already deployed +- ✅ KV namespace created (VIDEO_METADATA) +- ✅ Admin password set +- ✅ Wrangler CLI authenticated + +If you haven't set these up yet, run: +```bash +./scripts/setup-admin.sh +``` + +## Deploy Steps + +### 1. Build the Enhanced Worker + +```bash +cd /home/jeffe/Github/obs-r2-uploader +python3 scripts/build-worker.py +``` + +This embeds the updated admin.html with upload features into the worker. + +### 2. Verify Configuration + +Make sure `worker/wrangler.toml` has: + +```toml +[[kv_namespaces]] +binding = "VIDEO_METADATA" +id = "your_kv_namespace_id" # Should already be set + +[[r2_buckets]] +binding = "R2_BUCKET" +bucket_name = "obs-videos" +``` + +### 3. Deploy to Cloudflare + +```bash +cd worker +wrangler deploy +``` + +Expected output: +``` +Published obs-video-server (X.XX sec) + https://videos.jeffemmett.com +``` + +### 4. Test the Upload Feature + +1. Visit: `https://videos.jeffemmett.com/admin` +2. Login with your admin password +3. Look for the green **📤 Upload Video** button in the header +4. Click it and try uploading a video + +### 5. Test Empty State (Optional) + +If you have no videos yet: +1. The admin panel should show a nice empty state +2. With the message "No videos yet" +3. And an upload button + +## Verification Checklist + +- [ ] Admin panel loads without errors +- [ ] Upload button visible in header +- [ ] Can open upload modal +- [ ] Can drag-and-drop video files +- [ ] Can click to browse files +- [ ] Upload progress bar shows +- [ ] Video appears in list after upload +- [ ] Can set visibility after upload +- [ ] Empty state shows when no videos (if applicable) + +## Rollback (If Needed) + +If something goes wrong, you can rollback: + +```bash +cd worker +git checkout HEAD~1 video-server.js +wrangler deploy +``` + +Or redeploy the previous version: + +```bash +wrangler rollback +``` + +## Troubleshooting + +### Upload button not showing + +```bash +# Clear browser cache +# Hard refresh: Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac) + +# Or try incognito mode +``` + +### Upload fails with 404 + +Check that the upload endpoint exists: +```bash +# In worker/video-server.js, search for: +# path === '/admin/api/upload' +``` + +Should be present in the deployed worker. + +### "Not found" error + +Make sure you built the worker first: +```bash +python3 scripts/build-worker.py +``` + +Then deploy again. + +### KV namespace errors + +Verify KV namespace: +```bash +wrangler kv:namespace list +``` + +Update `wrangler.toml` with correct ID if needed. + +## Quick Deploy Command + +One-liner to rebuild and deploy: + +```bash +python3 scripts/build-worker.py && cd worker && wrangler deploy && cd .. +``` + +## What to Expect + +### Before Deployment +- No upload button in admin +- Empty state just says "No videos found" +- Can only upload via command line + +### After Deployment +- Green upload button in admin header +- Nice empty state with icon and message +- Can drag-and-drop videos +- Real-time upload progress +- Automatic list refresh after upload + +## Next Steps + +After successful deployment: + +1. **Test Upload**: Upload a test video via admin panel +2. **Set Visibility**: Try changing video visibility settings +3. **Share Videos**: Copy shareable links +4. **Create Clips**: Test the clip generation feature +5. **Read Docs**: Check [UPLOAD_FEATURE.md](UPLOAD_FEATURE.md) for full details + +## Support + +Having issues? Check: + +- [ADMIN.md](ADMIN.md) - Admin panel documentation +- [UPLOAD_FEATURE.md](UPLOAD_FEATURE.md) - Upload feature details +- [SETUP.md](SETUP.md) - Initial setup guide + +Or check worker logs: +```bash +wrangler tail +``` + +--- + +**Deployment Version:** 1.1 +**Last Updated:** 2024-11-22 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9fb2380 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.11-slim + +# Install system dependencies for clipboard support +RUN apt-get update && apt-get install -y \ + xclip \ + xsel \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application files +COPY obs_uploader/ ./obs_uploader/ +COPY scripts/ ./scripts/ +COPY .env.example . + +# Make scripts executable +RUN chmod +x scripts/*.sh + +# Default command +CMD ["/bin/bash"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..04725e6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Jeff Emmett + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/OBS_SETUP.md b/OBS_SETUP.md new file mode 100644 index 0000000..ec9fd93 --- /dev/null +++ b/OBS_SETUP.md @@ -0,0 +1,120 @@ +# OBS Auto-Upload Setup Guide + +## How It Works + +When you **record** in OBS, the file watcher automatically: +1. Detects when you stop recording (file size stabilizes) +2. Uploads the video to Cloudflare R2 +3. Copies the public URL to your clipboard +4. Optionally deletes the local file (if enabled) + +## Quick Setup + +### 1. Start the File Watcher + +In WSL terminal: + +```bash +cd ~/Github/obs-r2-uploader +./start-obs-watcher.sh +``` + +**Leave this running in the background!** + +### 2. Record in OBS + +1. Open OBS Studio (Windows) +2. Click **Start Recording** (not Stream!) +3. Do your recording +4. Click **Stop Recording** +5. Wait ~5 seconds for upload to complete +6. Public URL is automatically copied to your clipboard! + +### 3. Access Your Video + +- **Direct link**: Paste from clipboard (e.g., `https://videos.jeffemmett.com/2025-11-22_15-30-45.mkv`) +- **Gallery**: Visit `https://videos.jeffemmett.com/gallery` +- **Admin Panel**: `https://videos.jeffemmett.com/admin` + +## Configuration Options + +### Auto-Delete Local Files + +Edit `.env`: + +```env +AUTO_DELETE_AFTER_UPLOAD=true +``` + +This will automatically delete local recordings after successful upload. + +### Change Recording Directory + +If you change OBS recording path: + +1. In OBS: Settings → Output → Recording Path +2. Update `.env`: + ```env + OBS_RECORDING_DIR=/mnt/c/Users/jeffe/NewPath + ``` + +## Running at Startup (Optional) + +### Option 1: Windows Startup (Task Scheduler) + +1. Open Task Scheduler +2. Create Basic Task +3. Trigger: At startup +4. Action: Start a program + - Program: `wsl.exe` + - Arguments: `bash -c "cd ~/Github/obs-r2-uploader && ./start-obs-watcher.sh"` + +### Option 2: Manual Start + +Just run the script whenever you plan to record: +```bash +./start-obs-watcher.sh +``` + +## Troubleshooting + +### Uploads not working? + +Check if watcher is running: +```bash +ps aux | grep file_watcher +``` + +### Can't access Windows files? + +Make sure WSL can see Windows: +```bash +ls /mnt/c/Users/jeffe/Videos +``` + +### Wrong recording directory? + +Check OBS settings: +- Settings → Output → Recording → Recording Path + +## Pro Tips + +1. **Run in tmux/screen** so it survives terminal closes: + ```bash + tmux new -s obs-watcher + ./start-obs-watcher.sh + # Press Ctrl+B then D to detach + ``` + +2. **Check upload status**: + The watcher prints status messages for each upload + +3. **Test with a short recording** before your important streams + +4. **Videos are shareable immediately** at videos.jeffemmett.com + +## Need Help? + +- Check logs in the terminal where watcher is running +- Test manual upload: `./scripts/upload.sh /mnt/c/Users/jeffe/Videos/test.mkv` +- Verify credentials in `.env` file diff --git a/QUICK_SETUP.md b/QUICK_SETUP.md new file mode 100644 index 0000000..447f4c8 --- /dev/null +++ b/QUICK_SETUP.md @@ -0,0 +1,165 @@ +# Quick Setup - Make Your Worker Live + +Your worker is deployed but needs configuration to enable admin features. + +## Current Status +✅ Worker deployed +✅ R2 bucket `obs-videos` exists +❌ KV namespace missing (needed for admin) +❌ Admin password not set +❌ Custom domain not configured + +## 3-Minute Setup + +### 1. Create KV Namespace (30 seconds) +```bash +cd /home/jeffe/Github/obs-r2-uploader/worker +wrangler kv namespace create VIDEO_METADATA +``` + +**Example output:** +``` +⛅️ wrangler 4.50.0 +🌀 Creating namespace with title "obs-video-server-VIDEO_METADATA" +✨ Success! +Add the following to your configuration file in your kv_namespaces array: +{ binding = "VIDEO_METADATA", id = "abc123def456..." } +``` + +**COPY THE ID** (the `abc123def456...` part) + +### 2. Update Configuration (1 minute) +```bash +# Copy enhanced config +cp wrangler-enhanced.toml wrangler.toml + +# Edit the file +nano wrangler.toml +``` + +Find this line: +```toml +id = "placeholder_id" +``` + +Replace with YOUR ID: +```toml +id = "abc123def456..." # Use your actual ID from step 1 +``` + +Save and exit (Ctrl+X, then Y, then Enter) + +### 3. Set Admin Password (30 seconds) +```bash +wrangler secret put ADMIN_PASSWORD +``` + +Type your password when prompted, then press Enter. + +### 4. Deploy (30 seconds) +```bash +wrangler deploy +``` + +### 5. Add Custom Domain (1 minute) + +**Option A: Via Dashboard (Recommended)** +1. Go to: https://dash.cloudflare.com/ +2. Click **Workers & Pages** +3. Click **obs-video-server** +4. Click **Settings** → **Domains & Routes** +5. Click **Add Custom Domain** +6. Enter: `videos.jeffemmett.com` +7. Click **Add Domain** + +**Option B: Via Command Line** +```bash +# Edit wrangler.toml and uncomment these lines: +[env.production] +routes = [ + { pattern = "videos.jeffemmett.com/*", custom_domain = true } +] + +# Then deploy again +wrangler deploy +``` + +## Done! 🎉 + +Now test: + +1. **Visit**: `https://videos.jeffemmett.com/admin` +2. **Login** with your password +3. **See** the beautiful empty state +4. **Upload** a test video + +## What Gets Enabled + +Without KV namespace: +- ❌ Admin login +- ❌ Video upload via browser +- ❌ Visibility controls +- ❌ Video metadata +- ❌ Delete videos + +With KV namespace: +- ✅ Admin login +- ✅ Video upload via browser +- ✅ Visibility controls (Private/Shareable/Clip) +- ✅ Video metadata +- ✅ Delete videos +- ✅ Beautiful empty states +- ✅ Statistics dashboard + +## Troubleshooting + +### "Namespace already exists" +```bash +# List existing namespaces +wrangler kv namespace list + +# Use the ID from an existing VIDEO_METADATA namespace +``` + +### "Secret not found" +```bash +# Set it again +wrangler secret put ADMIN_PASSWORD +``` + +### "Worker not found" +```bash +# Make sure you're in the worker directory +cd /home/jeffe/Github/obs-r2-uploader/worker +wrangler deploy +``` + +### Can't access admin panel +1. Check worker is deployed: `wrangler deployments list` +2. Check password is set: `wrangler secret list` +3. Check KV is bound: `cat wrangler.toml | grep VIDEO_METADATA` +4. Try the worker URL first before custom domain + +## Quick Copy-Paste Commands + +All in one: +```bash +cd /home/jeffe/Github/obs-r2-uploader/worker +wrangler kv namespace create VIDEO_METADATA +# Copy the ID shown +cp wrangler-enhanced.toml wrangler.toml +nano wrangler.toml +# Replace placeholder_id with your ID, save and exit +wrangler secret put ADMIN_PASSWORD +# Enter your password +wrangler deploy +``` + +Then add custom domain via dashboard. + +--- + +**Need help?** Check logs: +```bash +wrangler tail +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..af8b887 --- /dev/null +++ b/README.md @@ -0,0 +1,293 @@ +# OBS to Cloudflare R2 Direct Upload System + +Upload OBS Studio recordings directly to Cloudflare R2 storage with public video sharing capabilities. No YouTube required! + +## Features + +- **Direct Upload**: Upload video files to Cloudflare R2 with a single command +- **Progress Tracking**: Real-time upload progress with visual feedback +- **Auto-Upload**: File watcher automatically uploads new OBS recordings +- **Public Sharing**: Instantly shareable video URLs via videos.jeffemmett.com +- **Video Gallery**: Beautiful web gallery to browse all your videos +- **Admin Panel**: Web-based admin interface to manage video visibility +- **Privacy Controls**: Set videos as Private, Shareable, or Clip-Shareable +- **Clip Generation**: Create shareable time-based clips from longer videos +- **Clipboard Integration**: Public URLs automatically copied to clipboard +- **Large File Support**: Multipart uploads for videos over 100MB +- **Cross-Platform**: Works on Windows, macOS, and Linux + +## Quick Start + +### 1. Installation + +```bash +# Run the setup script +./scripts/setup.sh + +# Authenticate with Cloudflare +wrangler login +``` + +### 2. Configuration + +Edit `.env` file with your Cloudflare R2 credentials: + +```env +R2_ACCOUNT_ID=your_account_id +R2_ACCESS_KEY_ID=your_access_key +R2_SECRET_ACCESS_KEY=your_secret_key +R2_BUCKET_NAME=obs-videos +PUBLIC_DOMAIN=videos.jeffemmett.com +``` + +### 3. Deploy + +```bash +# Create R2 bucket and deploy worker +./scripts/deploy.sh +``` + +### 4. Upload Your First Video + +```bash +# Upload a video +./scripts/upload.sh /path/to/video.mp4 + +# The public URL will be displayed and copied to your clipboard +``` + +## Usage + +### Manual Upload + +Upload a single video file: + +```bash +./scripts/upload.sh /path/to/video.mp4 +``` + +Upload with custom name: + +```bash +./scripts/upload.sh /path/to/video.mp4 my-awesome-video.mp4 +``` + +### Automatic Upload (File Watcher) + +Watch a directory and auto-upload new recordings: + +```bash +# Watch a specific directory +./scripts/start-watcher.sh /path/to/obs/recordings + +# Or set OBS_RECORDING_DIR in .env and run: +./scripts/start-watcher.sh +``` + +The file watcher will: +- Monitor the directory for new video files +- Wait until recording is complete (file size stable) +- Automatically upload the video +- Copy the public URL to clipboard +- Optionally delete the local file (if `AUTO_DELETE_AFTER_UPLOAD=true`) + +### View Your Videos + +- **Direct Access**: `https://videos.jeffemmett.com/video-name.mp4` +- **Gallery View**: `https://videos.jeffemmett.com/gallery` +- **JSON API**: `https://videos.jeffemmett.com/` or `https://videos.jeffemmett.com/api/list` +- **Admin Panel**: `https://videos.jeffemmett.com/admin` (requires authentication) + +## Admin Panel + +Manage your video library with a powerful web-based admin interface. + +### Features + +- **📤 Manual Upload**: Drag-and-drop video files directly in the browser +- **Video Visibility Control** + - 🔒 **Private**: Only you can view + - 🔗 **Shareable**: Anyone with link can view + - ✂️ **Clip Shareable**: Only time-based clips are shareable + +- **Clip Generation**: Create shareable clips with start/end times +- **Bulk Management**: Search, filter, and manage multiple videos +- **Delete Videos**: Remove videos directly from the admin panel +- **Statistics Dashboard**: See breakdown of video visibility +- **Upload Progress**: Real-time progress tracking for uploads +- **Empty State**: Helpful UI when no videos exist + +### Setup Admin Panel + +```bash +# Setup KV namespace and admin password +./scripts/setup-admin.sh + +# Build worker with embedded admin interface +python3 scripts/build-worker.py + +# Deploy +cd worker && wrangler deploy +``` + +Access admin panel at: `https://videos.jeffemmett.com/admin` + +See [ADMIN.md](ADMIN.md) for complete admin documentation. + +## Project Structure + +``` +obs-r2-uploader/ +├── obs_uploader/ # Python upload package +│ ├── upload.py # Core upload script +│ ├── file_watcher.py # Auto-upload daemon +│ └── config.py # Configuration management +├── worker/ # Cloudflare Worker +│ ├── video-server.js # Video serving logic +│ └── wrangler.toml # Worker configuration +├── scripts/ # Convenience scripts +│ ├── setup.sh # One-command setup +│ ├── deploy.sh # Deploy worker +│ ├── upload.sh # Upload wrapper +│ └── start-watcher.sh # Start file watcher +├── .env # Your configuration (not in git) +├── .env.example # Configuration template +└── requirements.txt # Python dependencies +``` + +## Configuration Options + +All configuration is done via `.env` file: + +| Variable | Required | Description | +|----------|----------|-------------| +| `R2_ACCOUNT_ID` | Yes | Your Cloudflare account ID | +| `R2_ACCESS_KEY_ID` | Yes | R2 API access key | +| `R2_SECRET_ACCESS_KEY` | Yes | R2 API secret key | +| `R2_BUCKET_NAME` | Yes | Name of R2 bucket (default: obs-videos) | +| `R2_ENDPOINT` | Auto | R2 endpoint URL (auto-generated) | +| `PUBLIC_DOMAIN` | Yes | Your public domain (videos.jeffemmett.com) | +| `OBS_RECORDING_DIR` | No | Default directory for file watcher | +| `AUTO_DELETE_AFTER_UPLOAD` | No | Delete local file after upload (default: false) | + +## Supported Video Formats + +- MP4 (`.mp4`) +- MKV (`.mkv`) +- MOV (`.mov`) +- AVI (`.avi`) +- WebM (`.webm`) +- FLV (`.flv`) +- WMV (`.wmv`) + +## Advanced Usage + +### Python Module + +Use as a Python module in your own scripts: + +```python +from obs_uploader.config import Config +from obs_uploader.upload import R2Uploader + +# Load configuration +config = Config() + +# Create uploader +uploader = R2Uploader(config) + +# Upload a file +public_url = uploader.upload_file(Path("/path/to/video.mp4")) +print(f"Uploaded: {public_url}") +``` + +### Custom Worker Routes + +Edit `worker/wrangler.toml` to customize routes and domains: + +```toml +[env.production] +routes = [ + { pattern = "videos.jeffemmett.com/*", custom_domain = true } +] +``` + +### Gallery Customization + +Edit `worker/video-server.js` to customize the gallery appearance and functionality. + +## Troubleshooting + +### Upload fails with "Invalid credentials" + +- Verify your R2 API credentials in `.env` +- Ensure the API token has read/write permissions for R2 +- Check that the bucket name matches + +### Videos not accessible via public URL + +- Verify CORS is configured (see SETUP.md) +- Check that custom domain is set up in Cloudflare dashboard +- Ensure worker is deployed: `cd worker && wrangler deploy` + +### File watcher not detecting new files + +- Verify the watch directory path is correct +- Check file permissions on the directory +- Ensure video file format is supported + +### Clipboard not working on Linux + +Install xclip or xsel: +```bash +sudo apt-get install xclip +# or +sudo apt-get install xsel +``` + +## Performance + +- **Small files (<100MB)**: Simple upload with progress bar +- **Large files (>100MB)**: Automatic multipart upload with 10MB chunks +- **Network resilience**: Automatic retries with exponential backoff +- **CDN caching**: Cloudflare automatically caches videos globally + +## Security + +- All R2 credentials stored in `.env` (gitignored) +- Videos are publicly accessible by URL (no authentication) +- Consider signed URLs for sensitive content (not implemented yet) +- CORS configured for browser access + +## Cost Considerations + +Cloudflare R2 pricing (as of 2024): +- **Storage**: $0.015/GB per month +- **Class A Operations** (writes): $4.50 per million requests +- **Class B Operations** (reads): $0.36 per million requests +- **Egress**: FREE (no bandwidth charges!) + +Example costs for 100GB of videos: +- Storage: $1.50/month +- 1000 uploads: ~$0.005 +- 10,000 views: ~$0.004 + +## Contributing + +This is a personal project, but feel free to fork and adapt for your own use! + +## License + +MIT License - See LICENSE file for details + +## Acknowledgments + +Built with: +- [Cloudflare R2](https://www.cloudflare.com/products/r2/) - Object storage +- [Cloudflare Workers](https://workers.cloudflare.com/) - Edge computing +- [boto3](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) - AWS SDK for Python +- [watchdog](https://python-watchdog.readthedocs.io/) - File system monitoring + +--- + +Made with ❤️ by Jeff Emmett diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..946ad19 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,397 @@ +# OBS R2 Uploader - Complete Setup Guide + +Step-by-step guide to get your OBS recording upload system running. + +## Prerequisites + +Before you begin, ensure you have: + +- [x] **Node.js 18+** installed ([download](https://nodejs.org/)) +- [x] **Python 3.8+** installed ([download](https://www.python.org/downloads/)) +- [x] **OBS Studio** installed ([download](https://obsproject.com/)) +- [x] **Cloudflare account** with R2 enabled ([sign up](https://dash.cloudflare.com/sign-up)) +- [x] **Domain in Cloudflare** (jeffemmett.com in your case) + +## Step 1: Clone and Install + +```bash +# Navigate to the project directory +cd obs-r2-uploader + +# Run setup script +./scripts/setup.sh +``` + +This will: +- Check prerequisites +- Install Python dependencies +- Install Wrangler CLI (if needed) +- Create `.env` file from template + +## Step 2: Authenticate with Cloudflare + +```bash +# Login to Cloudflare via browser +wrangler login +``` + +This opens your browser for OAuth authentication. + +Verify authentication: +```bash +wrangler whoami +``` + +## Step 3: Create R2 Bucket and API Tokens + +### Option A: Using the Deploy Script (Recommended) + +```bash +./scripts/deploy.sh +``` + +This creates the bucket and deploys the worker. + +### Option B: Manual Setup + +```bash +# Create bucket +wrangler r2 bucket create obs-videos + +# Verify creation +wrangler r2 bucket list +``` + +### Generate R2 API Tokens + +You need API tokens for the Python upload script: + +1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com/) +2. Navigate to **R2** in the sidebar +3. Click on your **obs-videos** bucket +4. Go to **Settings** → **R2 API Tokens** +5. Click **Create API Token** +6. Configure: + - **Token name**: `obs-uploader` + - **Permissions**: Admin Read & Write + - **TTL**: No expiry (or set as desired) +7. Click **Create API Token** +8. **SAVE THESE CREDENTIALS** - you'll need them for `.env` + +You'll receive: +- Access Key ID +- Secret Access Key + +## Step 4: Get Your Account ID + +```bash +# Get account ID from wrangler +wrangler whoami +``` + +Or from dashboard: +1. Go to any page in Cloudflare Dashboard +2. Look at the URL: `https://dash.cloudflare.com/{ACCOUNT_ID}/...` +3. The account ID is in the URL + +## Step 5: Configure Environment Variables + +Edit `.env` file with your credentials: + +```env +# Replace with your actual values +R2_ACCOUNT_ID=your_account_id_here +R2_ACCESS_KEY_ID=your_access_key_here +R2_SECRET_ACCESS_KEY=your_secret_access_key_here +R2_BUCKET_NAME=obs-videos + +# Auto-generated endpoint (replace account-id) +R2_ENDPOINT=https://your_account_id_here.r2.cloudflarestorage.com + +# Your public domain +PUBLIC_DOMAIN=videos.jeffemmett.com + +# Optional: Set your OBS recording directory +OBS_RECORDING_DIR=/path/to/obs/recordings + +# Optional: Auto-delete after upload +AUTO_DELETE_AFTER_UPLOAD=false +``` + +## Step 6: Configure CORS for R2 Bucket + +CORS must be configured to allow browser access to videos. + +### Via Cloudflare Dashboard: + +1. Go to **R2** → **obs-videos** bucket +2. Navigate to **Settings** → **CORS Policy** +3. Click **Add CORS Policy** +4. Configure: + ```json + { + "AllowedOrigins": ["*"], + "AllowedMethods": ["GET", "HEAD"], + "AllowedHeaders": ["*"], + "MaxAgeSeconds": 3600 + } + ``` +5. Click **Save** + +### Via API (Alternative): + +```bash +# Using the provided CORS config +curl -X PUT \ + "https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/r2/buckets/obs-videos/cors" \ + -H "Authorization: Bearer {API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d @cors-config.json +``` + +## Step 7: Deploy Cloudflare Worker + +```bash +cd worker +wrangler deploy +cd .. +``` + +This deploys your video server worker. + +Verify deployment: +```bash +wrangler deployments list +``` + +## Step 8: Configure Custom Domain + +Set up `videos.jeffemmett.com` to point to your worker: + +### Option A: Via Dashboard (Recommended) + +1. Go to **Workers & Pages** in Cloudflare Dashboard +2. Click on your **obs-video-server** worker +3. Go to **Settings** → **Domains & Routes** +4. Click **Add Custom Domain** +5. Enter: `videos.jeffemmett.com` +6. Click **Add Domain** + +Cloudflare automatically: +- Creates DNS records +- Issues SSL certificate +- Routes traffic to your worker + +### Option B: Via Wrangler + +Edit `worker/wrangler.toml`: + +```toml +[env.production] +routes = [ + { pattern = "videos.jeffemmett.com/*", custom_domain = true } +] +``` + +Then deploy: +```bash +cd worker +wrangler deploy --env production +cd .. +``` + +## Step 9: Enable R2 Public Access + +You need to make the bucket publicly readable via your custom domain. + +### Via Dashboard: + +1. Go to **R2** → **obs-videos** bucket +2. Go to **Settings** → **Public Access** +3. Click **Connect Domain** +4. Select **Custom Domain**: `videos.jeffemmett.com` +5. Click **Connect** + +This creates a public URL for your bucket content. + +## Step 10: Test Upload + +Test that everything works: + +```bash +# Create and upload a test video (requires ffmpeg) +./scripts/test-upload.sh + +# Or upload an existing video +./scripts/upload.sh /path/to/your/video.mp4 +``` + +Expected output: +``` +Uploading video.mp4 (52.3 MB) +Uploading video.mp4: 100%|████████| 52.3M/52.3M [00:15<00:00, 3.48MB/s] +✓ Upload successful: video.mp4 + +============================================================ +✓ Upload successful! +============================================================ + +Public URL: https://videos.jeffemmett.com/video.mp4 + +The URL has been copied to your clipboard. +============================================================ +``` + +## Step 11: Test Video Access + +1. **Direct video URL**: Open the URL from step 10 in your browser +2. **Gallery view**: Visit `https://videos.jeffemmett.com/gallery` +3. **JSON API**: Visit `https://videos.jeffemmett.com/` + +## Step 12: Configure OBS (Optional) + +Set up OBS to save recordings to a known directory: + +1. Open **OBS Studio** +2. Go to **Settings** → **Output** +3. Note your **Recording Path** +4. Add this path to `.env`: + ```env + OBS_RECORDING_DIR=/path/from/obs/settings + ``` + +## Step 13: Set Up Auto-Upload (Optional) + +Start the file watcher to automatically upload new recordings: + +```bash +./scripts/start-watcher.sh +``` + +Or specify a directory: +```bash +./scripts/start-watcher.sh /path/to/obs/recordings +``` + +The watcher will: +- Monitor for new video files +- Wait until recording is complete +- Automatically upload +- Copy URL to clipboard +- Optionally delete local file + +## Verification Checklist + +- [ ] Wrangler authenticated (`wrangler whoami`) +- [ ] R2 bucket created (`wrangler r2 bucket list`) +- [ ] `.env` configured with credentials +- [ ] CORS configured on bucket +- [ ] Worker deployed successfully +- [ ] Custom domain connected (`videos.jeffemmett.com`) +- [ ] Test video uploaded successfully +- [ ] Video accessible via public URL +- [ ] Gallery page loads correctly +- [ ] File watcher runs (optional) + +## Troubleshooting + +### Error: "Wrangler not found" + +```bash +npm install -g wrangler +``` + +### Error: "Invalid R2 credentials" + +- Double-check `.env` values +- Regenerate R2 API token if needed +- Ensure endpoint URL is correct + +### Error: "Bucket not found" + +```bash +# List buckets +wrangler r2 bucket list + +# Create bucket if missing +wrangler r2 bucket create obs-videos +``` + +### Error: "Worker deployment failed" + +```bash +# Check logs +wrangler tail + +# Redeploy +cd worker && wrangler deploy +``` + +### Videos return 404 + +- Verify custom domain is connected +- Check worker binding in `wrangler.toml` +- Ensure bucket name matches in config + +### CORS errors in browser + +- Verify CORS is configured on R2 bucket +- Check browser console for specific CORS errors +- May need to wait a few minutes for CORS changes to propagate + +### Upload fails on large files + +- Check internet connection stability +- Multipart upload should handle large files automatically +- Try upload with smaller test file first + +## Finding Your OBS Recording Directory + +### Windows +Default: `C:\Users\{Username}\Videos` + +1. Open OBS +2. Settings → Output → Recording Path + +### macOS +Default: `~/Movies` + +1. Open OBS +2. Preferences → Output → Recording Path + +### Linux +Default: `~/Videos` + +1. Open OBS +2. Settings → Output → Recording Path + +## Next Steps + +- Configure OBS to record to a specific directory +- Set up the file watcher for auto-uploads +- Share your video links! +- Consider setting up video thumbnails (future feature) +- Explore the gallery customization options + +## Getting Help + +If you encounter issues: + +1. Check the [README.md](README.md) troubleshooting section +2. Verify all environment variables in `.env` +3. Check Cloudflare dashboard for errors +4. Review worker logs: `wrangler tail` + +## Security Best Practices + +1. **Never commit `.env`** - it's in `.gitignore` +2. **Rotate API keys periodically** +3. **Use separate tokens** for different environments +4. **Monitor R2 usage** in Cloudflare dashboard +5. **Consider signed URLs** for sensitive videos (not implemented yet) + +--- + +**Setup Complete!** 🎉 + +You can now upload OBS recordings directly to R2 and share them via `videos.jeffemmett.com`. diff --git a/UPLOAD_FEATURE.md b/UPLOAD_FEATURE.md new file mode 100644 index 0000000..4f2b829 --- /dev/null +++ b/UPLOAD_FEATURE.md @@ -0,0 +1,220 @@ +# Manual Upload Feature - Admin Panel + +## What's New + +The admin panel now includes a manual video upload feature with drag-and-drop support! + +### New Features + +1. **📤 Upload Button** - Click the green "Upload Video" button in the admin header +2. **Drag & Drop Upload** - Drop video files directly into the upload modal +3. **Progress Tracking** - Real-time upload progress bar +4. **Empty State** - When no videos exist, shows a helpful message with upload button +5. **Auto-Refresh** - Video list automatically reloads after successful upload + +## Upload Methods + +### Method 1: Admin Panel Upload (NEW!) + +1. Login to admin panel: `https://videos.jeffemmett.com/admin` +2. Click "📤 Upload Video" button +3. Either: + - Drag and drop a video file into the upload area + - Click the upload area to browse files +4. Watch the progress bar +5. Video appears in your list automatically! + +### Method 2: Command Line Upload (Existing) + +```bash +./scripts/upload.sh /path/to/video.mp4 +``` + +### Method 3: Auto-Upload (Existing) + +```bash +./scripts/start-watcher.sh /path/to/obs/recordings +``` + +## Supported Formats + +- MP4 (`.mp4`) +- MKV (`.mkv`) +- MOV (`.mov`) +- AVI (`.avi`) +- WebM (`.webm`) +- FLV (`.flv`) +- WMV (`.wmv`) + +## Default Settings + +Videos uploaded via admin panel: +- ✅ Default visibility: **Shareable** +- ✅ Stored in R2 bucket: `obs-videos` +- ✅ Accessible via: `videos.jeffemmett.com/{filename}` +- ✅ Can change visibility after upload + +## Empty State Improvements + +When no videos are stored, the admin panel now shows: +- 🎥 Empty state icon +- "No videos yet" message +- Helpful "Upload your first video" text +- Upload button for easy access + +Instead of just "No videos found" + +## Technical Details + +### Upload Flow + +1. **Client Side**: + - User selects video file + - JavaScript validates file type + - Creates FormData with video file + - Uses XMLHttpRequest for progress tracking + - Shows progress bar with percentage + +2. **Server Side** (Cloudflare Worker): + - Receives multipart form data + - Validates file extension + - Uploads to R2 bucket with proper content-type + - Creates metadata entry (visibility: shareable) + - Returns success response + +3. **After Upload**: + - Admin panel reloads video list + - New video appears with thumbnail + - Default visibility is "Shareable" + - Ready to share immediately + +### API Endpoint + +``` +POST /admin/api/upload +Content-Type: multipart/form-data +Authentication: Required (admin cookie) + +Form data: +- video: File object + +Response (200 OK): +{ + "success": true, + "filename": "my-video.mp4", + "url": "/my-video.mp4" +} +``` + +## Deployment + +### Update Your Worker + +```bash +# Rebuild worker with new features +python3 scripts/build-worker.py + +# Deploy to Cloudflare +cd worker +wrangler deploy +``` + +### Verify Deployment + +1. Visit: `https://videos.jeffemmett.com/admin` +2. Login with your admin password +3. Look for the green "📤 Upload Video" button +4. If no videos exist, you should see the nice empty state + +## Advantages of Admin Upload + +### vs Command Line: +- ✅ No terminal needed +- ✅ Visual progress tracking +- ✅ Drag and drop support +- ✅ Works from any device +- ✅ Instant visibility management + +### vs Auto-Upload: +- ✅ Upload any video file (not just OBS recordings) +- ✅ Upload from anywhere +- ✅ No file watcher needed +- ✅ Immediate feedback +- ✅ Can upload multiple files sequentially + +## Use Cases + +### Quick Sharing +1. Record video on phone +2. Upload via admin panel +3. Share link immediately + +### Mixed Content Sources +- OBS recordings (auto-upload) +- Screen captures (manual upload) +- Downloaded videos (manual upload) +- Edited videos (manual upload) + +### Emergency Uploads +- Network issues with auto-upload +- Need to upload from different device +- Quick share from public computer + +## Limitations + +- **One file at a time**: Can't upload multiple files simultaneously +- **Browser memory**: Very large files (>2GB) may cause browser issues +- **No resume**: If upload fails, must restart +- **Timeout**: Very large files may timeout (Cloudflare 100MB request limit on free plan) + +### For Large Files + +Use the command-line upload instead: +```bash +./scripts/upload.sh /path/to/large-video.mp4 +``` + +The CLI supports: +- Multipart uploads for files >100MB +- Resume capability +- Better progress tracking for large files + +## Troubleshooting + +### Upload Button Not Visible +- Clear browser cache +- Verify worker deployment: `cd worker && wrangler deploy` +- Check browser console for errors + +### Upload Fails +- Check file size (Cloudflare free tier: 100MB request limit) +- Verify file format is supported +- Check network connection +- Try command-line upload for large files + +### Empty State Not Showing +- Refresh the page +- Check browser console for errors +- Verify KV namespace is configured + +### Progress Bar Stuck +- Network issue - check connection +- File too large - use CLI upload +- Refresh page and try again + +## Future Enhancements + +Potential improvements: +- [ ] Multiple file upload +- [ ] Upload queue +- [ ] Resume failed uploads +- [ ] Chunked uploads for large files +- [ ] Thumbnail preview before upload +- [ ] Custom filename on upload +- [ ] Set visibility before upload +- [ ] Bulk upload via zip file + +--- + +**Updated:** 2024-11-22 +**Version:** 1.1 diff --git a/cors-config.json b/cors-config.json new file mode 100644 index 0000000..dba4e18 --- /dev/null +++ b/cors-config.json @@ -0,0 +1,10 @@ +{ + "CORSRules": [ + { + "AllowedOrigins": ["*"], + "AllowedMethods": ["GET", "HEAD"], + "AllowedHeaders": ["*"], + "MaxAgeSeconds": 3600 + } + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d0ef0b4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: '3.8' + +services: + obs-uploader: + build: . + container_name: obs-uploader + volumes: + # Mount your OBS recordings directory + - ${OBS_RECORDING_DIR:-./recordings}:/recordings + # Mount .env file for credentials + - ./.env:/app/.env:ro + # Mount obs_uploader for development + - ./obs_uploader:/app/obs_uploader + - ./scripts:/app/scripts + environment: + - DISPLAY=${DISPLAY} + # For clipboard support on Linux + network_mode: host + stdin_open: true + tty: true + command: /bin/bash diff --git a/obs_scripts/auto_upload.py b/obs_scripts/auto_upload.py new file mode 100644 index 0000000..af14781 --- /dev/null +++ b/obs_scripts/auto_upload.py @@ -0,0 +1,244 @@ +""" +OBS Python Script for Automatic R2 Upload + +To use this script in OBS: +1. Open OBS Studio +2. Go to Tools → Scripts +3. Click the "+" button to add a script +4. Select this file (auto_upload.py) +5. Configure the settings in the script panel +6. Enable the script + +Requirements: +- Python 3.8+ must be configured in OBS (Tools → Scripts → Python Settings) +- obs_uploader package must be installed +- .env file must be configured with R2 credentials +""" + +import obspython as obs +import os +import sys +import subprocess +from pathlib import Path + +# Script settings +enabled = True +upload_script_path = "" +project_root = "" +show_notifications = True +auto_delete = False + + +def script_description(): + """Return the description shown in OBS.""" + return """

OBS R2 Auto-Upload

+

Automatically uploads completed recordings to Cloudflare R2.

+

Setup:

+
    +
  1. Set the project root directory (where .env file is located)
  2. +
  3. Ensure Python dependencies are installed
  4. +
  5. Configure .env with R2 credentials
  6. +
  7. Enable the script
  8. +
+

When you stop recording, the video will be automatically uploaded.

+ """ + + +def script_properties(): + """Define script properties shown in OBS settings.""" + props = obs.obs_properties_create() + + obs.obs_properties_add_bool( + props, + "enabled", + "Enable Auto-Upload" + ) + + obs.obs_properties_add_path( + props, + "project_root", + "Project Root Directory", + obs.OBS_PATH_DIRECTORY, + "", + "" + ) + + obs.obs_properties_add_bool( + props, + "show_notifications", + "Show Upload Notifications" + ) + + obs.obs_properties_add_bool( + props, + "auto_delete", + "Delete Local File After Upload" + ) + + return props + + +def script_defaults(settings): + """Set default values for settings.""" + obs.obs_data_set_default_bool(settings, "enabled", True) + obs.obs_data_set_default_bool(settings, "show_notifications", True) + obs.obs_data_set_default_bool(settings, "auto_delete", False) + + +def script_update(settings): + """Called when settings are updated.""" + global enabled, project_root, show_notifications, auto_delete + + enabled = obs.obs_data_get_bool(settings, "enabled") + project_root = obs.obs_data_get_string(settings, "project_root") + show_notifications = obs.obs_data_get_bool(settings, "show_notifications") + auto_delete = obs.obs_data_get_bool(settings, "auto_delete") + + print(f"[R2 Upload] Settings updated:") + print(f" Enabled: {enabled}") + print(f" Project Root: {project_root}") + print(f" Notifications: {show_notifications}") + print(f" Auto-delete: {auto_delete}") + + +def script_load(settings): + """Called when the script is loaded.""" + print("[R2 Upload] Script loaded") + + # Connect to recording stopped signal + obs.obs_frontend_add_event_callback(on_event) + + +def script_unload(): + """Called when the script is unloaded.""" + print("[R2 Upload] Script unloaded") + + +def on_event(event): + """Handle OBS events.""" + if not enabled: + return + + # Recording stopped event + if event == obs.OBS_FRONTEND_EVENT_RECORDING_STOPPED: + print("[R2 Upload] Recording stopped, preparing upload...") + + # Get the last recording path + recording_path = get_last_recording_path() + + if recording_path: + print(f"[R2 Upload] Found recording: {recording_path}") + upload_recording(recording_path) + else: + print("[R2 Upload] Could not determine recording path") + + +def get_last_recording_path(): + """Get the path of the last recording.""" + # Get recording path from OBS output settings + output = obs.obs_frontend_get_recording_output() + + if output: + settings = obs.obs_output_get_settings(output) + path = obs.obs_data_get_string(settings, "path") + + obs.obs_data_release(settings) + obs.obs_output_release(output) + + if path and os.path.exists(path): + return path + + # Alternative: Get from config + config = obs.obs_frontend_get_profile_config() + if config: + # Try different possible config keys + keys = [ + "AdvOut.RecFilePath", + "AdvOut.FFFilePath", + "SimpleOutput.FilePath" + ] + + for key in keys: + path = obs.config_get_string(config, "Output", key) + if path: + # This gives us the directory, not the file + # We'd need to find the most recent file + if os.path.isdir(path): + return get_most_recent_video(path) + + return None + + +def get_most_recent_video(directory): + """Get the most recently modified video file in a directory.""" + video_extensions = ['.mp4', '.mkv', '.mov', '.avi', '.flv', '.wmv', '.webm'] + + video_files = [] + for ext in video_extensions: + video_files.extend(Path(directory).glob(f'*{ext}')) + + if not video_files: + return None + + # Get most recent file + most_recent = max(video_files, key=lambda p: p.stat().st_mtime) + return str(most_recent) + + +def upload_recording(file_path): + """Upload a recording to R2.""" + if not project_root: + print("[R2 Upload] Error: Project root not configured") + return + + if not os.path.exists(project_root): + print(f"[R2 Upload] Error: Project root does not exist: {project_root}") + return + + # Construct command + upload_script = os.path.join(project_root, "obs_uploader", "upload.py") + + if not os.path.exists(upload_script): + print(f"[R2 Upload] Error: Upload script not found: {upload_script}") + return + + # Set environment variable for auto-delete + env = os.environ.copy() + if auto_delete: + env["AUTO_DELETE_AFTER_UPLOAD"] = "true" + + # Build command + cmd = [sys.executable, upload_script, file_path] + + print(f"[R2 Upload] Running: {' '.join(cmd)}") + + try: + # Run upload in background + result = subprocess.Popen( + cmd, + cwd=project_root, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + + # Don't wait for completion - it can take a while + print(f"[R2 Upload] Upload started (PID: {result.pid})") + + if show_notifications: + # Note: OBS doesn't have a built-in notification system + # This just prints to the log + print(f"[R2 Upload] Uploading {os.path.basename(file_path)} to R2...") + + except Exception as e: + print(f"[R2 Upload] Error starting upload: {e}") + + +# Note: For this script to work properly in OBS, you need to: +# 1. Configure Python Settings in OBS to point to your Python installation +# 2. Ensure the Python environment has access to the obs_uploader package +# 3. Make sure .env file is properly configured +# +# Alternative approach: Use the file_watcher.py script instead, which runs +# independently of OBS and is more reliable. diff --git a/obs_uploader/__init__.py b/obs_uploader/__init__.py new file mode 100644 index 0000000..dcb3d36 --- /dev/null +++ b/obs_uploader/__init__.py @@ -0,0 +1,3 @@ +"""OBS to R2 Uploader - Upload OBS recordings to Cloudflare R2 storage.""" + +__version__ = "1.0.0" diff --git a/obs_uploader/config.py b/obs_uploader/config.py new file mode 100644 index 0000000..471b5b2 --- /dev/null +++ b/obs_uploader/config.py @@ -0,0 +1,78 @@ +"""Configuration management for OBS R2 Uploader.""" + +import os +from typing import Optional +from pathlib import Path +from dotenv import load_dotenv + + +class Config: + """Configuration manager for R2 upload settings.""" + + def __init__(self, env_path: Optional[str] = None): + """Initialize configuration from environment variables. + + Args: + env_path: Optional path to .env file. If None, searches in current and parent dirs. + """ + if env_path: + load_dotenv(env_path) + else: + # Search for .env in current directory and parent directories + current_dir = Path.cwd() + for parent in [current_dir] + list(current_dir.parents): + env_file = parent / ".env" + if env_file.exists(): + load_dotenv(env_file) + break + + # Required settings + self.account_id = os.getenv("R2_ACCOUNT_ID") + self.access_key_id = os.getenv("R2_ACCESS_KEY_ID") + self.secret_access_key = os.getenv("R2_SECRET_ACCESS_KEY") + self.bucket_name = os.getenv("R2_BUCKET_NAME", "obs-videos") + + # Construct endpoint URL + if self.account_id: + self.endpoint = os.getenv( + "R2_ENDPOINT", + f"https://{self.account_id}.r2.cloudflarestorage.com" + ) + else: + self.endpoint = os.getenv("R2_ENDPOINT") + + # Public domain for sharing + self.public_domain = os.getenv("PUBLIC_DOMAIN", "videos.jeffemmett.com") + + # Optional settings + self.obs_recording_dir = os.getenv("OBS_RECORDING_DIR") + self.auto_delete = os.getenv("AUTO_DELETE_AFTER_UPLOAD", "false").lower() == "true" + + def validate(self) -> tuple[bool, list[str]]: + """Validate that all required configuration is present. + + Returns: + Tuple of (is_valid, list of missing settings) + """ + required = { + "R2_ACCOUNT_ID": self.account_id, + "R2_ACCESS_KEY_ID": self.access_key_id, + "R2_SECRET_ACCESS_KEY": self.secret_access_key, + "R2_ENDPOINT": self.endpoint, + } + + missing = [key for key, value in required.items() if not value] + return (len(missing) == 0, missing) + + def get_public_url(self, filename: str) -> str: + """Generate public URL for uploaded file. + + Args: + filename: Name of the uploaded file + + Returns: + Full public URL + """ + # Remove protocol if present in public_domain + domain = self.public_domain.replace("https://", "").replace("http://", "") + return f"https://{domain}/{filename}" diff --git a/obs_uploader/file_watcher.py b/obs_uploader/file_watcher.py new file mode 100644 index 0000000..4ecee99 --- /dev/null +++ b/obs_uploader/file_watcher.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""File watcher for automatic upload of new OBS recordings.""" + +import sys +import time +import logging +import threading +from pathlib import Path +from typing import Optional +from collections import defaultdict +from datetime import datetime + +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileModifiedEvent + +from .config import Config +from .upload import R2Uploader + + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger(__name__) + + +class VideoFileHandler(FileSystemEventHandler): + """Handler for video file system events.""" + + # Supported video extensions + VIDEO_EXTENSIONS = {'.mp4', '.mkv', '.mov', '.avi', '.webm', '.flv', '.wmv'} + + # Time to wait before considering a file "stable" (not being written to) + STABILITY_TIMEOUT = 5 # seconds + + def __init__(self, uploader: R2Uploader, config: Config): + """Initialize the video file handler. + + Args: + uploader: R2Uploader instance + config: Configuration object + """ + super().__init__() + self.uploader = uploader + self.config = config + + # Track file sizes to detect when writing is complete + self.file_sizes = defaultdict(lambda: (0, time.time())) + + # Track files that have been uploaded + self.uploaded_files = set() + + def check_pending_files(self): + """Check all pending files for stability and upload if ready.""" + for file_path_str in list(self.file_sizes.keys()): + if file_path_str in self.uploaded_files: + continue + + file_path = Path(file_path_str) + if not file_path.exists(): + # File was deleted, remove from tracking + del self.file_sizes[file_path_str] + continue + + if self.is_file_stable(file_path): + logger.info(f"File appears stable, preparing to upload: {file_path.name}") + self.upload_file(file_path) + + def is_video_file(self, path: str) -> bool: + """Check if the file is a video file. + + Args: + path: File path to check + + Returns: + True if it's a video file + """ + return Path(path).suffix.lower() in self.VIDEO_EXTENSIONS + + def is_file_stable(self, file_path: Path) -> bool: + """Check if file has stopped being written to. + + Args: + file_path: Path to the file + + Returns: + True if file size hasn't changed for STABILITY_TIMEOUT seconds + """ + try: + current_size = file_path.stat().st_size + last_size, last_check = self.file_sizes[str(file_path)] + + # If size changed, update and reset timer + if current_size != last_size: + self.file_sizes[str(file_path)] = (current_size, time.time()) + return False + + # If size hasn't changed and enough time has passed + if time.time() - last_check >= self.STABILITY_TIMEOUT: + return True + + return False + + except FileNotFoundError: + return False + except Exception as e: + logger.error(f"Error checking file stability: {e}") + return False + + def on_created(self, event): + """Handle file creation event. + + Args: + event: File system event + """ + if isinstance(event, FileCreatedEvent) and not event.is_directory: + if self.is_video_file(event.src_path): + logger.info(f"New video file detected: {event.src_path}") + file_path = Path(event.src_path) + + # Initialize size tracking + try: + current_size = file_path.stat().st_size + self.file_sizes[event.src_path] = (current_size, time.time()) + except Exception: + self.file_sizes[event.src_path] = (0, time.time()) + + def on_modified(self, event): + """Handle file modification event. + + Args: + event: File system event + """ + if isinstance(event, FileModifiedEvent) and not event.is_directory: + if self.is_video_file(event.src_path): + file_path = Path(event.src_path) + + # Skip if already uploaded + if str(file_path) in self.uploaded_files: + return + + # Check if file is stable (done being written) + if self.is_file_stable(file_path): + logger.info(f"File appears stable, preparing to upload: {file_path.name}") + self.upload_file(file_path) + + def upload_file(self, file_path: Path): + """Upload a video file to R2. + + Args: + file_path: Path to the video file + """ + try: + logger.info(f"Starting upload: {file_path.name}") + public_url = self.uploader.upload_file(file_path) + + if public_url: + # Mark as uploaded + self.uploaded_files.add(str(file_path)) + + print(f"\n{'='*60}") + print(f"✓ Auto-upload successful!") + print(f"{'='*60}") + print(f"File: {file_path.name}") + print(f"URL: {public_url}") + print(f"{'='*60}\n") + + # Auto-delete if configured + if self.config.auto_delete: + try: + file_path.unlink() + logger.info(f"Deleted local file: {file_path}") + except Exception as e: + logger.error(f"Failed to delete local file: {e}") + + else: + logger.error(f"Upload failed: {file_path.name}") + + except Exception as e: + logger.error(f"Error uploading file: {e}") + + finally: + # Clean up tracking + if str(file_path) in self.file_sizes: + del self.file_sizes[str(file_path)] + + +class FileWatcher: + """Watch a directory for new video files and auto-upload them.""" + + def __init__(self, watch_dir: Path, config: Config): + """Initialize the file watcher. + + Args: + watch_dir: Directory to watch for new videos + config: Configuration object + """ + self.watch_dir = watch_dir + self.config = config + + if not watch_dir.exists(): + raise ValueError(f"Watch directory does not exist: {watch_dir}") + + if not watch_dir.is_dir(): + raise ValueError(f"Watch path is not a directory: {watch_dir}") + + # Initialize uploader + self.uploader = R2Uploader(config) + + # Create event handler and observer + self.event_handler = VideoFileHandler(self.uploader, config) + self.observer = Observer() + self.observer.schedule(self.event_handler, str(watch_dir), recursive=False) + + logger.info(f"Initialized file watcher for: {watch_dir}") + + def start(self): + """Start watching the directory.""" + logger.info("Starting file watcher...") + print(f"\n{'='*60}") + print(f"OBS R2 Auto-Uploader - File Watcher Active") + print(f"{'='*60}") + print(f"Watching: {self.watch_dir}") + print(f"Bucket: {self.config.bucket_name}") + print(f"Public Domain: {self.config.public_domain}") + print(f"Auto-delete: {'Enabled' if self.config.auto_delete else 'Disabled'}") + print(f"{'='*60}") + print(f"Press Ctrl+C to stop\n") + + self.observer.start() + + try: + while True: + # Periodically check for stable files + self.event_handler.check_pending_files() + time.sleep(1) + except KeyboardInterrupt: + logger.info("Stopping file watcher...") + self.stop() + + def stop(self): + """Stop watching the directory.""" + self.observer.stop() + self.observer.join() + logger.info("File watcher stopped") + + +def main(): + """Main entry point for file watcher CLI.""" + # Load configuration first + try: + config = Config() + except Exception as e: + logger.error(f"Failed to load configuration: {e}") + sys.exit(1) + + # Determine watch directory + if len(sys.argv) >= 2: + watch_dir = Path(sys.argv[1]) + elif config.obs_recording_dir: + watch_dir = Path(config.obs_recording_dir) + else: + print("Usage: python -m obs_uploader.file_watcher ") + print("\nExample:") + print(" python -m obs_uploader.file_watcher /path/to/obs/recordings") + print("\nOr set OBS_RECORDING_DIR in .env and run without arguments:") + print(" python -m obs_uploader.file_watcher") + logger.error("\nNo watch directory specified.") + logger.error("Either provide a directory as an argument or set OBS_RECORDING_DIR in .env") + sys.exit(1) + + # Start file watcher + try: + watcher = FileWatcher(watch_dir, config) + watcher.start() + except KeyboardInterrupt: + logger.info("Stopped by user") + sys.exit(0) + except Exception as e: + logger.error(f"Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/obs_uploader/hls_uploader.py b/obs_uploader/hls_uploader.py new file mode 100644 index 0000000..1d1214f --- /dev/null +++ b/obs_uploader/hls_uploader.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Real-time HLS chunk uploader to R2.""" + +import sys +import time +import logging +from pathlib import Path +from typing import Optional, Set +from collections import defaultdict + +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileModifiedEvent + +from .config import Config +from .upload import R2Uploader + + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger(__name__) + + +class HLSChunkHandler(FileSystemEventHandler): + """Handler for HLS chunk files (.ts and .m3u8).""" + + def __init__(self, uploader: R2Uploader, config: Config, stream_prefix: str = "live"): + """Initialize the HLS chunk handler. + + Args: + uploader: R2Uploader instance + config: Configuration object + stream_prefix: Prefix for R2 object names (e.g., "live/") + """ + super().__init__() + self.uploader = uploader + self.config = config + self.stream_prefix = stream_prefix + self.uploaded_files: Set[str] = set() + self.pending_files: Set[str] = set() + + def is_hls_file(self, path: str) -> bool: + """Check if file is an HLS file (.m3u8 or .ts). + + Args: + path: File path to check + + Returns: + True if it's an HLS file + """ + return Path(path).suffix.lower() in {'.m3u8', '.ts'} + + def on_created(self, event): + """Handle file creation event. + + Args: + event: File system event + """ + if isinstance(event, FileCreatedEvent) and not event.is_directory: + if self.is_hls_file(event.src_path): + logger.info(f"New HLS chunk detected: {Path(event.src_path).name}") + self.pending_files.add(event.src_path) + + def on_modified(self, event): + """Handle file modification event. + + Args: + event: File system event + """ + if isinstance(event, FileModifiedEvent) and not event.is_directory: + if self.is_hls_file(event.src_path): + # Always add .m3u8 files (they update frequently) + # Only add .ts files if not already uploaded + if event.src_path.endswith('.m3u8') or event.src_path not in self.uploaded_files: + self.pending_files.add(event.src_path) + + def upload_chunk(self, file_path: Path): + """Upload HLS chunk to R2. + + Args: + file_path: Path to the chunk file + """ + try: + # Build R2 object name: live/{stream_name}/{filename} + # Extract stream name from filename (e.g., my-stream-0.ts -> my-stream) + filename = file_path.name + + # Stream name is the base name before the segment number + # e.g., "my-stream-0.ts" -> "my-stream" + # "my-stream.m3u8" -> "my-stream" + if filename.endswith('.m3u8'): + stream_name = filename.replace('.m3u8', '') + elif '-' in filename and filename.endswith('.ts'): + # Split on last hyphen to get stream name + stream_name = filename.rsplit('-', 1)[0] + else: + stream_name = filename.split('.')[0] + + object_name = f"{self.stream_prefix}/{stream_name}/{filename}" + + # Read file content + with open(file_path, 'rb') as f: + data = f.read() + + # Determine content type + content_type = 'application/vnd.apple.mpegurl' if file_path.suffix == '.m3u8' else 'video/MP2T' + + # Upload to R2 + self.uploader.s3_client.put_object( + Bucket=self.config.bucket_name, + Key=object_name, + Body=data, + ContentType=content_type, + CacheControl='no-cache' if file_path.suffix == '.m3u8' else 'public, max-age=31536000' + ) + + logger.info(f"✓ Uploaded: {object_name} ({len(data)} bytes)") + # Only track .ts files as uploaded (m3u8 needs to be re-uploaded on every change) + if file_path.suffix != '.m3u8': + self.uploaded_files.add(str(file_path)) + + except Exception as e: + logger.error(f"Failed to upload {file_path.name}: {e}") + + def process_pending(self): + """Process all pending files for upload.""" + for file_path_str in list(self.pending_files): + file_path = Path(file_path_str) + + # Skip if already uploaded + if file_path_str in self.uploaded_files: + self.pending_files.discard(file_path_str) + continue + + # Skip if file doesn't exist + if not file_path.exists(): + self.pending_files.discard(file_path_str) + continue + + # For .m3u8 files, upload immediately (playlist updates frequently) + # For .ts files, wait a moment to ensure they're fully written + if file_path.suffix == '.m3u8': + self.upload_chunk(file_path) + self.pending_files.discard(file_path_str) + else: + # Check if file size is stable + try: + size = file_path.stat().st_size + if size > 0: # Only upload if file has content + self.upload_chunk(file_path) + self.pending_files.discard(file_path_str) + except Exception: + pass + + +class HLSWatcher: + """Watch HLS directory and upload chunks to R2 in real-time.""" + + def __init__(self, hls_dir: Path, config: Config, stream_prefix: str = "live"): + """Initialize the HLS watcher. + + Args: + hls_dir: Directory where HLS files are written + config: Configuration object + stream_prefix: Prefix for R2 object names + """ + self.hls_dir = hls_dir + self.config = config + + if not hls_dir.exists(): + hls_dir.mkdir(parents=True, exist_ok=True) + + # Initialize uploader + self.uploader = R2Uploader(config) + + # Create event handler and observer + self.event_handler = HLSChunkHandler(self.uploader, config, stream_prefix) + self.observer = Observer() + self.observer.schedule(self.event_handler, str(hls_dir), recursive=True) + + logger.info(f"Initialized HLS watcher for: {hls_dir}") + + def upload_playlists(self): + """Upload all m3u8 playlist files in the directory.""" + for m3u8_file in self.hls_dir.glob("*.m3u8"): + if m3u8_file.exists(): + self.event_handler.upload_chunk(m3u8_file) + + def start(self): + """Start watching the HLS directory.""" + logger.info("Starting HLS watcher...") + print(f"\n{'='*60}") + print(f"HLS Real-Time Uploader - Active") + print(f"{'='*60}") + print(f"Watching: {self.hls_dir}") + print(f"Bucket: {self.config.bucket_name}") + print(f"Public Domain: {self.config.public_domain}") + print(f"{'='*60}") + print(f"Press Ctrl+C to stop\n") + + self.observer.start() + + playlist_upload_counter = 0 + + try: + while True: + # Process pending uploads every second + self.event_handler.process_pending() + + # Upload playlists every 2 seconds + playlist_upload_counter += 1 + if playlist_upload_counter >= 2: + self.upload_playlists() + playlist_upload_counter = 0 + + time.sleep(1) + except KeyboardInterrupt: + logger.info("Stopping HLS watcher...") + self.stop() + + def stop(self): + """Stop watching the directory.""" + self.observer.stop() + self.observer.join() + logger.info("HLS watcher stopped") + + +def main(): + """Main entry point for HLS uploader CLI.""" + if len(sys.argv) < 2: + print("Usage: python -m obs_uploader.hls_uploader ") + print("\nExample:") + print(" python -m obs_uploader.hls_uploader /home/user/obs-r2-uploader/streaming/hls") + sys.exit(1) + + hls_dir = Path(sys.argv[1]) + + # Load configuration + try: + config = Config() + except Exception as e: + logger.error(f"Failed to load configuration: {e}") + sys.exit(1) + + # Start HLS watcher + try: + watcher = HLSWatcher(hls_dir, config) + watcher.start() + except KeyboardInterrupt: + logger.info("Stopped by user") + sys.exit(0) + except Exception as e: + logger.error(f"Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/obs_uploader/upload.py b/obs_uploader/upload.py new file mode 100644 index 0000000..2d54284 --- /dev/null +++ b/obs_uploader/upload.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""Upload video files to Cloudflare R2 storage.""" + +import sys +import os +import logging +from pathlib import Path +from typing import Optional +import mimetypes + +import boto3 +from botocore.config import Config as BotoConfig +from botocore.exceptions import ClientError, NoCredentialsError +from tqdm import tqdm +import pyperclip + +from .config import Config + + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) +logger = logging.getLogger(__name__) + + +class R2Uploader: + """Upload videos to Cloudflare R2 with progress tracking.""" + + # Supported video formats + SUPPORTED_FORMATS = {'.mp4', '.mkv', '.mov', '.avi', '.webm', '.flv', '.wmv'} + + # Multipart upload threshold (100MB) + MULTIPART_THRESHOLD = 100 * 1024 * 1024 + MULTIPART_CHUNKSIZE = 10 * 1024 * 1024 # 10MB chunks + + def __init__(self, config: Config): + """Initialize the R2 uploader. + + Args: + config: Configuration object with R2 credentials + """ + self.config = config + + # Validate configuration + is_valid, missing = config.validate() + if not is_valid: + raise ValueError(f"Missing required configuration: {', '.join(missing)}") + + # Initialize S3 client for R2 + self.s3_client = boto3.client( + 's3', + endpoint_url=config.endpoint, + aws_access_key_id=config.access_key_id, + aws_secret_access_key=config.secret_access_key, + config=BotoConfig( + signature_version='s3v4', + retries={'max_attempts': 3, 'mode': 'adaptive'}, + # Disable payload signing for R2 compatibility + s3={'payload_signing_enabled': False} + ) + ) + + logger.info(f"Initialized R2 uploader for bucket: {config.bucket_name}") + + def validate_file(self, file_path: Path) -> bool: + """Validate that the file exists and is a supported video format. + + Args: + file_path: Path to the video file + + Returns: + True if valid, False otherwise + """ + if not file_path.exists(): + logger.error(f"File does not exist: {file_path}") + return False + + if not file_path.is_file(): + logger.error(f"Path is not a file: {file_path}") + return False + + if file_path.suffix.lower() not in self.SUPPORTED_FORMATS: + logger.error( + f"Unsupported file format: {file_path.suffix}. " + f"Supported formats: {', '.join(self.SUPPORTED_FORMATS)}" + ) + return False + + return True + + def get_content_type(self, file_path: Path) -> str: + """Determine the MIME type for the file. + + Args: + file_path: Path to the file + + Returns: + MIME type string + """ + mime_type, _ = mimetypes.guess_type(str(file_path)) + return mime_type or 'application/octet-stream' + + def upload_file( + self, + file_path: Path, + object_name: Optional[str] = None, + public: bool = True + ) -> Optional[str]: + """Upload a file to R2. + + Args: + file_path: Path to the file to upload + object_name: S3 object name. If not specified, file_path.name is used + public: Whether to make the file publicly readable + + Returns: + Public URL of the uploaded file, or None if upload failed + """ + if not self.validate_file(file_path): + return None + + # Use filename as object name if not specified + if object_name is None: + object_name = file_path.name + + file_size = file_path.stat().st_size + content_type = self.get_content_type(file_path) + + logger.info(f"Uploading {file_path.name} ({file_size / (1024*1024):.2f} MB)") + + try: + # Prepare extra arguments + extra_args = { + 'ContentType': content_type, + } + + # Add public-read ACL if requested (R2 handles this differently) + # For R2, we'll rely on bucket-level public access configuration + + # Use multipart upload for large files + if file_size > self.MULTIPART_THRESHOLD: + logger.info("Using multipart upload for large file") + self._upload_multipart(file_path, object_name, extra_args) + else: + self._upload_simple(file_path, object_name, extra_args) + + logger.info(f"✓ Upload successful: {object_name}") + + # Generate public URL + public_url = self.config.get_public_url(object_name) + + return public_url + + except NoCredentialsError: + logger.error("Invalid R2 credentials") + return None + except ClientError as e: + logger.error(f"Upload failed: {e}") + return None + except Exception as e: + logger.error(f"Unexpected error during upload: {e}") + return None + + def _upload_simple(self, file_path: Path, object_name: str, extra_args: dict): + """Upload file using simple put_object. + + Args: + file_path: Path to file + object_name: Object name in R2 + extra_args: Extra arguments for upload + """ + file_size = file_path.stat().st_size + + with open(file_path, 'rb') as f: + with tqdm( + total=file_size, + unit='B', + unit_scale=True, + desc=f"Uploading {file_path.name}" + ) as pbar: + # Read file into memory for small files to avoid checksum issues + data = f.read() + pbar.update(len(data)) + + self.s3_client.put_object( + Bucket=self.config.bucket_name, + Key=object_name, + Body=data, + **extra_args + ) + + def _upload_multipart(self, file_path: Path, object_name: str, extra_args: dict): + """Upload file using multipart upload with progress tracking. + + Args: + file_path: Path to file + object_name: Object name in R2 + extra_args: Extra arguments for upload + """ + # Use boto3's upload_file with automatic multipart handling + self.s3_client.upload_file( + str(file_path), + self.config.bucket_name, + object_name, + ExtraArgs=extra_args, + Config=boto3.s3.transfer.TransferConfig( + multipart_threshold=self.MULTIPART_THRESHOLD, + multipart_chunksize=self.MULTIPART_CHUNKSIZE, + use_threads=True + ), + Callback=ProgressCallback(file_path.name, file_path.stat().st_size) + ) + + +class ProgressCallback: + """Callback for tracking upload progress with tqdm.""" + + def __init__(self, filename: str, size: int): + """Initialize progress callback. + + Args: + filename: Name of file being uploaded + size: Total file size in bytes + """ + self.filename = filename + self.size = size + self.pbar = tqdm( + total=size, + unit='B', + unit_scale=True, + desc=f"Uploading {filename}" + ) + + def __call__(self, bytes_transferred: int): + """Update progress bar. + + Args: + bytes_transferred: Number of bytes transferred in this chunk + """ + self.pbar.update(bytes_transferred) + + def __del__(self): + """Close progress bar.""" + if hasattr(self, 'pbar'): + self.pbar.close() + + +def main(): + """Main entry point for CLI usage.""" + if len(sys.argv) < 2: + print("Usage: python -m obs_uploader.upload [object_name]") + print("\nExample:") + print(" python -m obs_uploader.upload /path/to/video.mp4") + print(" python -m obs_uploader.upload /path/to/video.mp4 my-custom-name.mp4") + sys.exit(1) + + file_path = Path(sys.argv[1]) + object_name = sys.argv[2] if len(sys.argv) > 2 else None + + # Load configuration + try: + config = Config() + except Exception as e: + logger.error(f"Failed to load configuration: {e}") + logger.error("Make sure .env file exists and contains required settings.") + logger.error("See .env.example for required configuration.") + sys.exit(1) + + # Create uploader and upload file + try: + uploader = R2Uploader(config) + public_url = uploader.upload_file(file_path, object_name) + + if public_url: + print(f"\n{'='*60}") + print(f"✓ Upload successful!") + print(f"{'='*60}") + print(f"\nPublic URL: {public_url}") + print(f"\nThe URL has been copied to your clipboard.") + print(f"{'='*60}\n") + + # Copy to clipboard + try: + pyperclip.copy(public_url) + except Exception as e: + logger.warning(f"Could not copy to clipboard: {e}") + + # Auto-delete if configured + if config.auto_delete: + try: + file_path.unlink() + logger.info(f"Deleted local file: {file_path}") + except Exception as e: + logger.error(f"Failed to delete local file: {e}") + + sys.exit(0) + else: + logger.error("Upload failed") + sys.exit(1) + + except Exception as e: + logger.error(f"Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/package.json b/package.json new file mode 100644 index 0000000..43cdfbb --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "obs-video-server", + "version": "1.0.0", + "description": "Cloudflare Worker for serving OBS videos from R2", + "main": "worker/video-server.js", + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev", + "test": "wrangler dev --local" + }, + "keywords": [ + "cloudflare", + "worker", + "r2", + "obs", + "video" + ], + "author": "Jeff Emmett", + "license": "MIT" +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a88c178 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +boto3>=1.28.0 +python-dotenv>=1.0.0 +tqdm>=4.66.0 +pyperclip>=1.8.2 +watchdog>=3.0.0 diff --git a/scripts/build-admin.sh b/scripts/build-admin.sh new file mode 100755 index 0000000..2e04de1 --- /dev/null +++ b/scripts/build-admin.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Build script to embed admin.html into the worker + +set -e + +echo "==================================================" +echo "Building Worker with Embedded Admin Panel" +echo "==================================================" +echo "" + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR" + +# Check if admin.html exists +if [ ! -f "worker/admin.html" ]; then + echo "✗ admin.html not found in worker directory" + exit 1 +fi + +# Read admin.html and escape it for JavaScript +ADMIN_HTML=$(cat worker/admin.html | sed 's/\\/\\\\/g' | sed 's/`/\\`/g' | sed 's/\$/\\$/g') + +# Create the embedded version +echo "Creating worker with embedded admin HTML..." + +# Read the enhanced worker +WORKER_CONTENT=$(cat worker/video-server-enhanced.js) + +# Replace the placeholder getAdminHTML function +# We'll create a new version that includes the actual HTML + +cat > worker/video-server-final.js << 'WORKER_START' +/** + * Enhanced Cloudflare Worker for serving videos from R2 storage + * Features: + * - Admin panel with authentication + * - Video visibility controls (private, shareable, clip_shareable) + * - Clip generation and serving + * - Permission-based access control + */ + +WORKER_START + +# Append the admin HTML as a constant +echo "const ADMIN_HTML = \`" >> worker/video-server-final.js +cat worker/admin.html >> worker/video-server-final.js +echo "\`;" >> worker/video-server-final.js +echo "" >> worker/video-server-final.js + +# Append the rest of the worker code, but skip the getAdminHTML placeholder +cat worker/video-server-enhanced.js | sed -n '/^export default/,$p' | \ + sed 's/async function getAdminHTML() {/async function getAdminHTML() { return ADMIN_HTML; } \/\/ OLD VERSION: {/g' >> worker/video-server-final.js + +echo "✓ Created video-server-final.js with embedded admin HTML" +echo "" +echo "To use this version:" +echo " cp worker/video-server-final.js worker/video-server.js" +echo " cd worker && wrangler deploy" +echo "" diff --git a/scripts/build-worker.py b/scripts/build-worker.py new file mode 100755 index 0000000..a5926b9 --- /dev/null +++ b/scripts/build-worker.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +Build script to embed admin.html into the Cloudflare Worker. +This creates a single deployable worker file with the admin interface embedded. +""" + +import os +import sys +from pathlib import Path + +def main(): + # Get project root + script_dir = Path(__file__).parent + project_dir = script_dir.parent + worker_dir = project_dir / "worker" + + admin_html_path = worker_dir / "admin.html" + worker_template_path = worker_dir / "video-server-enhanced.js" + output_path = worker_dir / "video-server.js" + + # Check if files exist + if not admin_html_path.exists(): + print(f"Error: {admin_html_path} not found") + sys.exit(1) + + if not worker_template_path.exists(): + print(f"Error: {worker_template_path} not found") + sys.exit(1) + + print("=" * 50) + print("Building Cloudflare Worker with Embedded Admin") + print("=" * 50) + print() + + # Read admin HTML + print("Reading admin.html...") + with open(admin_html_path, 'r', encoding='utf-8') as f: + admin_html = f.read() + + # Escape the HTML for JavaScript template literal + admin_html_escaped = admin_html.replace('\\', '\\\\').replace('`', '\\`').replace('${', '\\${') + + # Read worker template + print("Reading worker template...") + with open(worker_template_path, 'r', encoding='utf-8') as f: + worker_code = f.read() + + # Replace the getAdminHTML function with one that returns the embedded HTML + placeholder = '''async function getAdminHTML() { + // In production, you'd embed this or fetch from R2 + // For now, we'll import it as a module or use a fetch + // Since we can't easily read files in Workers, we'll need to inline it or use a build step + + // For this implementation, we'll fetch it from a constant + // In production, use wrangler's module support or embed it + + return ` + +Admin Panel + +

Admin Panel

+

Please replace this with the full admin.html content using a build step or module import.

+

For now, use: /admin/api/videos to see the API.

+ + + `; + // TODO: In production, import admin.html content here +}''' + + replacement = f'''async function getAdminHTML() {{ + return `{admin_html_escaped}`; +}}''' + + # Replace the placeholder + if placeholder in worker_code: + worker_code = worker_code.replace(placeholder, replacement) + print("✓ Embedded admin.html into worker") + else: + print("⚠️ Could not find placeholder to replace") + print(" Adding admin HTML as a constant instead...") + + # Alternative: add at the top + const_declaration = f'\nconst ADMIN_HTML = `{admin_html_escaped}`;\n\n' + worker_code = const_declaration + worker_code + + # Replace the simple return + simple_placeholder = 'async function getAdminHTML() {\n return ADMIN_HTML;' + if simple_placeholder not in worker_code: + worker_code = worker_code.replace( + 'async function getAdminHTML() {', + f'async function getAdminHTML() {{\n return ADMIN_HTML;\n /*' + ) + worker_code = worker_code.replace(' // TODO: In production', ' */') + + # Write output + print(f"Writing output to {output_path}...") + with open(output_path, 'w', encoding='utf-8') as f: + f.write(worker_code) + + print() + print("=" * 50) + print("Build Complete!") + print("=" * 50) + print() + print(f"Output: {output_path}") + print() + print("Next steps:") + print("1. Make sure wrangler.toml is configured (use wrangler-enhanced.toml as template)") + print("2. Deploy: cd worker && wrangler deploy") + print("3. Access admin at: https://videos.jeffemmett.com/admin") + print() + +if __name__ == "__main__": + main() diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..e24545c --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,108 @@ +#!/bin/bash +# Deploy script - Creates R2 bucket, configures CORS, and deploys worker + +set -e # Exit on error + +echo "==================================================" +echo "OBS R2 Uploader - Deployment Script" +echo "==================================================" +echo "" + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR" + +# Check if wrangler is authenticated +echo "Checking Wrangler authentication..." +if ! wrangler whoami &> /dev/null; then + echo "✗ Wrangler is not authenticated" + echo "Run 'wrangler login' first" + exit 1 +fi +echo "✓ Wrangler is authenticated" +echo "" + +# Create R2 bucket +echo "Creating R2 bucket 'obs-videos'..." +if wrangler r2 bucket create obs-videos 2>&1 | grep -q "already exists"; then + echo "✓ Bucket 'obs-videos' already exists" +else + echo "✓ Bucket 'obs-videos' created" +fi +echo "" + +# Configure CORS +echo "Configuring CORS for bucket..." + +# Load R2 credentials from .env +if [ -f .env ]; then + export $(grep -v '^#' .env | xargs) +fi + +# Check if credentials are available +if [ -z "$R2_ACCOUNT_ID" ] || [ -z "$R2_ACCESS_KEY_ID" ] || [ -z "$R2_SECRET_ACCESS_KEY" ]; then + echo "⚠ CORS configuration skipped - R2 credentials not found in .env" + echo " You can configure CORS manually in Cloudflare dashboard" +else + # Apply CORS configuration using AWS CLI-compatible API + # Check for AWS CLI in venv or system + if [ -f "venv/bin/aws" ]; then + AWS_CMD="venv/bin/aws" + elif command -v aws &> /dev/null; then + AWS_CMD="aws" + else + AWS_CMD="" + fi + + if [ -n "$AWS_CMD" ]; then + echo "Applying CORS configuration..." + AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID" \ + AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY" \ + $AWS_CMD s3api put-bucket-cors \ + --bucket obs-videos \ + --cors-configuration file://cors-config.json \ + --endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + + if [ $? -eq 0 ]; then + echo "✓ CORS configured successfully" + else + echo "⚠ CORS configuration failed - you may need to configure it manually" + echo " Go to R2 > obs-videos > Settings > CORS Policy in Cloudflare dashboard" + fi + else + echo "⚠ AWS CLI not found - skipping automatic CORS configuration" + echo " Run: venv/bin/pip install awscli" + echo " Or configure CORS manually in Cloudflare dashboard" + echo " Go to R2 > obs-videos > Settings > CORS Policy" + fi +fi +echo "" + +# List buckets to verify +echo "Verifying bucket creation..." +wrangler r2 bucket list +echo "" + +# Deploy worker +echo "Deploying Cloudflare Worker..." +cd worker +wrangler deploy +cd .. +echo "" + +echo "==================================================" +echo "Deployment complete!" +echo "==================================================" +echo "" +echo "Next steps:" +echo "1. Configure CORS in Cloudflare dashboard (see above)" +echo "2. Set up custom domain (videos.jeffemmett.com) in Cloudflare dashboard:" +echo " - Go to Workers & Pages > obs-video-server > Settings > Domains" +echo " - Add custom domain: videos.jeffemmett.com" +echo "3. Update .env with your R2 credentials:" +echo " - Go to R2 > obs-videos > Settings > R2 API Tokens" +echo " - Create a new API token with read/write access" +echo "4. Test upload: ./scripts/upload.sh /path/to/video.mp4" +echo "" diff --git a/scripts/setup-admin.sh b/scripts/setup-admin.sh new file mode 100755 index 0000000..5116e83 --- /dev/null +++ b/scripts/setup-admin.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Setup script for admin features + +set -e + +echo "==================================================" +echo "OBS R2 Uploader - Admin Setup" +echo "==================================================" +echo "" + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR/worker" + +# Check if wrangler is authenticated +echo "Checking Wrangler authentication..." +if ! wrangler whoami &> /dev/null; then + echo "✗ Wrangler is not authenticated" + echo "Run 'wrangler login' first" + exit 1 +fi +echo "✓ Wrangler is authenticated" +echo "" + +# Create KV namespace for video metadata +echo "Creating KV namespace for video metadata..." +if wrangler kv:namespace create VIDEO_METADATA 2>&1 | tee /tmp/kv_output.txt; then + echo "✓ KV namespace created" + + # Extract the KV namespace ID from output + KV_ID=$(grep -oP 'id = "\K[^"]+' /tmp/kv_output.txt || echo "") + + if [ -n "$KV_ID" ]; then + echo "KV Namespace ID: $KV_ID" + echo "" + echo "⚠️ IMPORTANT: Update wrangler-enhanced.toml with this ID:" + echo " Replace 'placeholder_id' with: $KV_ID" + echo "" + + # Automatically update the wrangler.toml file + if [ -f "wrangler-enhanced.toml" ]; then + sed -i "s/id = \"placeholder_id\"/id = \"$KV_ID\"/" wrangler-enhanced.toml + echo "✓ Updated wrangler-enhanced.toml with KV namespace ID" + fi + fi +else + echo "⚠️ KV namespace may already exist. Check with:" + echo " wrangler kv:namespace list" +fi +echo "" + +# Set admin password +echo "Setting up admin password..." +echo "Please enter a secure admin password:" +read -s ADMIN_PASSWORD +echo "" +echo "Confirm password:" +read -s ADMIN_PASSWORD_CONFIRM +echo "" + +if [ "$ADMIN_PASSWORD" != "$ADMIN_PASSWORD_CONFIRM" ]; then + echo "✗ Passwords do not match" + exit 1 +fi + +# Set the secret +echo "$ADMIN_PASSWORD" | wrangler secret put ADMIN_PASSWORD + +echo "" +echo "==================================================" +echo "Admin setup complete!" +echo "==================================================" +echo "" +echo "Next steps:" +echo "1. Copy the enhanced worker as the main worker:" +echo " cp video-server-enhanced.js video-server.js" +echo " cp wrangler-enhanced.toml wrangler.toml" +echo "" +echo "2. Build and embed the admin HTML:" +echo " ./build-admin.sh" +echo "" +echo "3. Deploy the worker:" +echo " wrangler deploy" +echo "" +echo "4. Access the admin panel:" +echo " https://videos.jeffemmett.com/admin" +echo "" diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..ba1c733 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# Setup script for OBS R2 Uploader +# Installs dependencies and configures the environment + +set -e # Exit on error + +echo "==================================================" +echo "OBS R2 Uploader - Setup Script" +echo "==================================================" +echo "" + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR" + +# Check Python version +echo "Checking Python version..." +if command -v python3 &> /dev/null; then + PYTHON_VERSION=$(python3 --version | cut -d' ' -f2) + echo "✓ Python $PYTHON_VERSION found" +else + echo "✗ Python 3 is not installed" + exit 1 +fi + +# Check Node.js version +echo "Checking Node.js version..." +if command -v node &> /dev/null; then + NODE_VERSION=$(node --version) + echo "✓ Node.js $NODE_VERSION found" +else + echo "✗ Node.js is not installed" + exit 1 +fi + +# Check if wrangler is installed +echo "Checking Wrangler CLI..." +if command -v wrangler &> /dev/null; then + WRANGLER_VERSION=$(wrangler --version) + echo "✓ Wrangler $WRANGLER_VERSION found" +else + echo "✗ Wrangler is not installed" + echo "Installing Wrangler globally..." + npm install -g wrangler +fi + +# Install Python dependencies +echo "" +echo "Installing Python dependencies..." + +# Check if virtual environment exists, create if not +if [ ! -d "venv" ]; then + echo "Creating virtual environment..." + python3 -m venv venv +fi + +# Install dependencies in virtual environment +echo "Installing packages in virtual environment..." +venv/bin/pip install -r requirements.txt + +echo "✓ Python dependencies installed in venv/" +echo " Activate with: source venv/bin/activate" + +# Check if .env exists +echo "" +if [ -f .env ]; then + echo "✓ .env file already exists" +else + echo "Creating .env file from template..." + cp .env.example .env + echo "⚠ Please edit .env file and add your Cloudflare R2 credentials" + echo " You can get these from the Cloudflare dashboard after creating an R2 bucket" +fi + +# Check if wrangler is authenticated +echo "" +echo "Checking Wrangler authentication..." +if wrangler whoami &> /dev/null; then + echo "✓ Wrangler is authenticated" +else + echo "⚠ Wrangler is not authenticated" + echo "Run 'wrangler login' to authenticate with Cloudflare" +fi + +echo "" +echo "==================================================" +echo "Setup complete!" +echo "==================================================" +echo "" +echo "Next steps:" +echo "1. Run 'wrangler login' to authenticate with Cloudflare (if not done)" +echo "2. Edit .env file with your R2 credentials" +echo "3. Run './scripts/deploy.sh' to create R2 bucket and deploy worker" +echo "4. Test upload with './scripts/upload.sh /path/to/video.mp4'" +echo "" diff --git a/scripts/start-watcher.bat b/scripts/start-watcher.bat new file mode 100644 index 0000000..7e2405f --- /dev/null +++ b/scripts/start-watcher.bat @@ -0,0 +1,19 @@ +@echo off +REM Start the file watcher for automatic uploads +REM Usage: start-watcher.bat [optional-watch-directory] + +REM Get the directory where this script is located +set SCRIPT_DIR=%~dp0 +set PROJECT_DIR=%SCRIPT_DIR%.. + +REM Change to project directory to ensure .env is found +cd /d "%PROJECT_DIR%" + +REM Run the file watcher +if "%~1"=="" ( + REM No arguments - use OBS_RECORDING_DIR from .env + python -m obs_uploader.file_watcher +) else ( + REM Use provided directory + python -m obs_uploader.file_watcher "%~1" +) diff --git a/scripts/start-watcher.sh b/scripts/start-watcher.sh new file mode 100755 index 0000000..ff04e6b --- /dev/null +++ b/scripts/start-watcher.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Start the file watcher for automatic uploads +# Usage: ./start-watcher.sh [optional-watch-directory] + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +# Change to project directory to ensure .env is found +cd "$PROJECT_DIR" + +# Use venv if it exists, otherwise use system python +if [ -d "venv" ]; then + PYTHON="venv/bin/python" +else + PYTHON="python3" +fi + +# Run the file watcher +if [ $# -eq 0 ]; then + # No arguments - use OBS_RECORDING_DIR from .env + $PYTHON -m obs_uploader.file_watcher +else + # Use provided directory + $PYTHON -m obs_uploader.file_watcher "$1" +fi diff --git a/scripts/test-upload.sh b/scripts/test-upload.sh new file mode 100755 index 0000000..f72ca7b --- /dev/null +++ b/scripts/test-upload.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Test upload script - Creates a small test video and uploads it + +set -e # Exit on error + +echo "==================================================" +echo "OBS R2 Uploader - Test Upload" +echo "==================================================" +echo "" + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_DIR" + +# Check if ffmpeg is installed (for creating test video) +if command -v ffmpeg &> /dev/null; then + echo "Creating test video with ffmpeg..." + + # Create tests directory if it doesn't exist + mkdir -p tests + + # Generate a 5-second test video + ffmpeg -f lavfi -i testsrc=duration=5:size=1280x720:rate=30 \ + -f lavfi -i sine=frequency=1000:duration=5 \ + -c:v libx264 -c:a aac -pix_fmt yuv420p \ + tests/test-video.mp4 -y + + TEST_VIDEO="tests/test-video.mp4" + echo "✓ Test video created: $TEST_VIDEO" +else + echo "⚠ ffmpeg not found - cannot create test video" + echo "Please provide a video file manually:" + echo " ./scripts/upload.sh /path/to/your/video.mp4" + exit 1 +fi + +echo "" +echo "Uploading test video..." +./scripts/upload.sh "$TEST_VIDEO" + +echo "" +echo "==================================================" +echo "Test upload complete!" +echo "==================================================" diff --git a/scripts/upload.bat b/scripts/upload.bat new file mode 100644 index 0000000..80daea6 --- /dev/null +++ b/scripts/upload.bat @@ -0,0 +1,13 @@ +@echo off +REM Upload a video file to Cloudflare R2 +REM Usage: upload.bat C:\path\to\video.mp4 [optional-object-name] + +REM Get the directory where this script is located +set SCRIPT_DIR=%~dp0 +set PROJECT_DIR=%SCRIPT_DIR%.. + +REM Change to project directory to ensure .env is found +cd /d "%PROJECT_DIR%" + +REM Run the upload script +python -m obs_uploader.upload %* diff --git a/scripts/upload.sh b/scripts/upload.sh new file mode 100755 index 0000000..b125af5 --- /dev/null +++ b/scripts/upload.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Upload a video file to Cloudflare R2 +# Usage: ./upload.sh /path/to/video.mp4 [optional-object-name] + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +# Change to project directory to ensure .env is found +cd "$PROJECT_DIR" + +# Use venv if it exists, otherwise use system python +if [ -d "venv" ]; then + PYTHON="venv/bin/python" +else + PYTHON="python3" +fi + +# Run the upload script +$PYTHON -m obs_uploader.upload "$@" diff --git a/start-obs-watcher.sh b/start-obs-watcher.sh new file mode 100755 index 0000000..3a7e9d0 --- /dev/null +++ b/start-obs-watcher.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Auto-upload OBS recordings to Cloudflare R2 +# Run this script to start watching for new recordings + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +cd "$SCRIPT_DIR" + +echo "==========================================" +echo "OBS Recording Auto-Uploader" +echo "==========================================" +echo "" +echo "This will monitor your OBS recordings directory" +echo "and automatically upload new videos to R2." +echo "" +echo "Press Ctrl+C to stop" +echo "" + +# Start the file watcher +./scripts/start-watcher.sh diff --git a/streaming/STREAMING-SETUP.md b/streaming/STREAMING-SETUP.md new file mode 100644 index 0000000..6ce8785 --- /dev/null +++ b/streaming/STREAMING-SETUP.md @@ -0,0 +1,241 @@ +# Live Streaming Setup Guide + +This guide will help you set up hybrid live streaming with HLS + automatic VOD archiving to R2. + +## Architecture + +``` +OBS → RTMP → nginx-rtmp → HLS chunks → Real-time uploader → R2 → Cloudflare Worker → Viewers + ↓ + VOD Archive +``` + +## Quick Start + +### 1. Start the Streaming Stack + +```bash +cd streaming +./start-streaming.sh +``` + +This will: +- Start nginx-rtmp server (Docker) +- Start HLS chunk uploader to R2 +- Display connection info + +### 2. Configure OBS + +#### Settings → Stream + +1. **Service**: Custom +2. **Server**: `rtmp://localhost/live` +3. **Stream Key**: `my-stream` (or choose your own name like `gaming`, `podcast`, etc.) + +#### Settings → Output + +**Recommended Settings for 1080p60:** + +- **Output Mode**: Advanced +- **Encoder**: x264 or Hardware (NVENC/AMD/QuickSync) +- **Rate Control**: CBR +- **Bitrate**: 6000 Kbps (adjust based on upload speed) +- **Keyframe Interval**: 2 seconds +- **CPU Usage Preset**: veryfast (or faster for lower-end CPUs) +- **Profile**: high +- **Tune**: zerolatency + +**For 720p60:** +- **Bitrate**: 4500 Kbps + +**For 1080p30:** +- **Bitrate**: 4500 Kbps + +#### Settings → Video + +- **Base Resolution**: 1920x1080 (or your monitor resolution) +- **Output Resolution**: 1920x1080 or 1280x720 +- **FPS**: 60 or 30 + +#### Settings → Advanced + +- **Process Priority**: High (optional, for smoother streaming) + +### 3. Start Streaming + +1. Click "Start Streaming" in OBS +2. Wait 5-10 seconds for HLS chunks to be created +3. Access your stream at: + - Local (testing): `http://localhost:8081/hls/my-stream.m3u8` + - Public: `https://videos.jeffemmett.com/live/my-stream/my-stream.m3u8` + +### 4. Watch Your Stream + +#### In a browser: +- Use the HLS.js player +- Or use VLC: Media → Open Network Stream → Enter URL + +#### Test Player HTML: +```html + + + + + + + + + + +``` + +### 5. Stop Streaming + +1. Click "Stop Streaming" in OBS +2. (Optional) Stop the streaming stack: + ```bash + ./stop-streaming.sh + ``` + +## How It Works + +### Real-Time Streaming + +1. **OBS** sends RTMP stream to `rtmp://localhost/live/my-stream` +2. **nginx-rtmp** receives the stream and segments it into HLS chunks: + - Creates `.m3u8` playlist (updated every 2 seconds) + - Creates `.ts` video chunks (2-second segments) +3. **HLS Uploader** watches the `hls/` directory: + - Detects new chunks as they're created + - Immediately uploads to R2 at `live/my-stream/` +4. **Cloudflare Worker** serves the chunks: + - Viewers request `.m3u8` playlist + - Player loads `.ts` chunks as they become available + - ~5-10 second latency end-to-end + +### Automatic VOD Archiving + +- All HLS chunks remain in R2 after stream ends +- Can be concatenated into full VOD recording +- Or left as is for HLS replay + +## Troubleshooting + +### Stream won't connect +- Check if nginx-rtmp is running: `docker ps | grep obs-streaming-server` +- Check nginx logs: `docker logs obs-streaming-server` +- Verify RTMP URL: `rtmp://localhost/live` (not `rtmp://localhost:1935/live`) + +### Stream is choppy/buffering +- Lower bitrate in OBS Output settings +- Check CPU usage - try faster encoder preset +- Check upload bandwidth - run speed test + +### Can't see stream on public URL +- Wait 5-10 seconds after starting stream +- Check HLS uploader logs: `tail -f streaming/hls-uploader.log` +- Verify chunks are being uploaded to R2 + +### High latency (>15 seconds) +- Normal for HLS - typical latency is 6-15 seconds +- Lower `hls_fragment` in nginx.conf to 1s (will increase bandwidth) +- Consider WebRTC for sub-second latency (different setup) + +## Advanced Configuration + +### Multiple Quality Levels + +Edit `streaming/nginx/nginx.conf`: + +```nginx +hls_variant _360p BANDWIDTH=800000; +hls_variant _720p BANDWIDTH=2800000; +hls_variant _1080p BANDWIDTH=5000000; +``` + +### Change Stream Key + +Use any stream key you want - it becomes the folder name in R2: +- Stream key: `my-stream` → R2 path: `live/my-stream/` (default) +- Stream key: `gaming` → R2 path: `live/gaming/` +- Stream key: `podcast-ep-5` → R2 path: `live/podcast-ep-5/` + +### Custom Domain + +If using a different domain, update in `.env`: +``` +PUBLIC_DOMAIN=streams.yourdomain.com +``` + +## Performance Notes + +### Bandwidth Usage + +- **720p60 @ 4500kbps**: ~2.0 GB/hour +- **1080p60 @ 6000kbps**: ~2.7 GB/hour +- **1080p30 @ 4500kbps**: ~2.0 GB/hour + +### R2 Costs + +- **Storage**: $0.015/GB/month +- **Class A Operations** (uploads): $4.50/million +- **Class B Operations** (downloads): $0.36/million +- **Egress**: Free to Cloudflare network + +Example cost for 1 hour stream at 1080p60: +- Storage: ~2.7GB × $0.015 = $0.04/month +- Uploads: ~1800 chunks × $0.0000045 = $0.008 +- **Total: ~$0.05** per hour streamed + +### Viewer Bandwidth + +Viewers consume: +- **720p**: ~4.5 Mbps +- **1080p**: ~6 Mbps + +## File Structure + +``` +streaming/ +├── docker-compose.yml # nginx-rtmp container config +├── nginx/ +│ └── nginx.conf # nginx RTMP + HLS config +├── scripts/ +│ ├── on_publish.sh # Called when stream starts +│ └── on_publish_done.sh # Called when stream ends +├── hls/ # HLS output directory (auto-created) +│ ├── my-stream.m3u8 # Playlist +│ └── my-stream-*.ts # Video chunks +├── start-streaming.sh # Start everything +├── stop-streaming.sh # Stop everything +└── STREAMING-SETUP.md # This file +``` + +## Next Steps + +- Create a custom player page +- Set up stream notifications (when stream goes live) +- Add stream recording/VOD conversion +- Implement chat/comments +- Add stream analytics + +## Support + +For issues, check: +1. Docker logs: `docker logs obs-streaming-server` +2. HLS uploader logs: `tail -f streaming/hls-uploader.log` +3. Nginx stats: `http://localhost:8081/stat` diff --git a/streaming/docker-compose.yml b/streaming/docker-compose.yml new file mode 100644 index 0000000..6fc7f40 --- /dev/null +++ b/streaming/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.8' + +services: + nginx-rtmp: + image: tiangolo/nginx-rtmp:latest + container_name: obs-streaming-server + ports: + - "1935:1935" # RTMP + - "8081:8080" # HLS/HTTP + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./hls:/tmp/hls + - ./scripts:/usr/local/bin:ro + restart: unless-stopped + networks: + - streaming + +networks: + streaming: + driver: bridge diff --git a/streaming/hls-uploader.pid b/streaming/hls-uploader.pid new file mode 100644 index 0000000..8f0fb5c --- /dev/null +++ b/streaming/hls-uploader.pid @@ -0,0 +1 @@ +40373 diff --git a/streaming/nginx/nginx.conf b/streaming/nginx/nginx.conf new file mode 100644 index 0000000..bb8579b --- /dev/null +++ b/streaming/nginx/nginx.conf @@ -0,0 +1,65 @@ +worker_processes auto; +rtmp_auto_push on; +events {} + +rtmp { + server { + listen 1935; + listen [::]:1935 ipv6only=on; + + application live { + live on; + record off; + + # HLS Configuration + hls on; + hls_path /tmp/hls; + hls_fragment 2s; + hls_playlist_length 10s; + + # HLS variants for different qualities + hls_variant _low BANDWIDTH=500000; + hls_variant _mid BANDWIDTH=1500000; + hls_variant _high BANDWIDTH=3000000; + + # Allow publishing from local network + allow publish 127.0.0.1; + allow publish 172.16.0.0/12; + deny publish all; + + # Allow playing from anywhere + allow play all; + + # Execute script on stream publish/done + exec_publish /usr/local/bin/on_publish.sh $name; + exec_publish_done /usr/local/bin/on_publish_done.sh $name; + } + } +} + +http { + server { + listen 8080; + + # Serve HLS fragments + location /hls { + types { + application/vnd.apple.mpegurl m3u8; + video/mp2t ts; + } + root /tmp; + add_header Cache-Control no-cache; + add_header Access-Control-Allow-Origin *; + } + + # Status page + location /stat { + rtmp_stat all; + rtmp_stat_stylesheet stat.xsl; + } + + location /stat.xsl { + root /usr/local/nginx/html; + } + } +} diff --git a/streaming/scripts/on_publish.sh b/streaming/scripts/on_publish.sh new file mode 100755 index 0000000..f40a8f9 --- /dev/null +++ b/streaming/scripts/on_publish.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Called when a stream starts publishing +STREAM_NAME=$1 +echo "[$(date)] Stream started: $STREAM_NAME" >> /tmp/hls/stream.log diff --git a/streaming/scripts/on_publish_done.sh b/streaming/scripts/on_publish_done.sh new file mode 100755 index 0000000..65a0c7e --- /dev/null +++ b/streaming/scripts/on_publish_done.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# Called when a stream stops publishing +STREAM_NAME=$1 +echo "[$(date)] Stream ended: $STREAM_NAME" >> /tmp/hls/stream.log diff --git a/streaming/start-streaming.sh b/streaming/start-streaming.sh new file mode 100755 index 0000000..d400568 --- /dev/null +++ b/streaming/start-streaming.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Start the complete streaming stack + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$SCRIPT_DIR" + +echo "==========================================" +echo "Starting OBS Live Streaming Stack" +echo "==========================================" +echo "" + +# Start nginx-rtmp via Docker Compose +echo "Starting nginx-rtmp server..." +docker-compose up -d + +echo "" +echo "Waiting for nginx to be ready..." +sleep 3 + +# Check if nginx is running +if ! docker ps | grep -q obs-streaming-server; then + echo "✗ Failed to start nginx-rtmp server" + exit 1 +fi + +echo "✓ nginx-rtmp server is running" +echo "" + +# Start HLS uploader in background +echo "Starting HLS chunk uploader..." +cd "$PROJECT_DIR" + +if [ -d "venv" ]; then + PYTHON="venv/bin/python" +else + PYTHON="python3" +fi + +# Run HLS uploader in background +nohup $PYTHON -m obs_uploader.hls_uploader "$SCRIPT_DIR/hls" > "$SCRIPT_DIR/hls-uploader.log" 2>&1 & +HLS_UPLOADER_PID=$! + +echo $HLS_UPLOADER_PID > "$SCRIPT_DIR/hls-uploader.pid" + +echo "✓ HLS uploader started (PID: $HLS_UPLOADER_PID)" +echo "" + +echo "==========================================" +echo "Streaming Stack is Ready!" +echo "==========================================" +echo "" +echo "RTMP URL: rtmp://localhost/live" +echo "Stream Key: my-stream" +echo "" +echo "Local HLS: http://localhost:8081/hls/my-stream.m3u8" +echo "Live URL: https://videos.jeffemmett.com/live/my-stream/my-stream.m3u8" +echo "" +echo "To stop:" +echo " ./stop-streaming.sh" +echo "" +echo "Logs:" +echo " HLS Uploader: tail -f $SCRIPT_DIR/hls-uploader.log" +echo " nginx: docker logs -f obs-streaming-server" +echo "" diff --git a/streaming/stop-streaming.sh b/streaming/stop-streaming.sh new file mode 100755 index 0000000..cecb979 --- /dev/null +++ b/streaming/stop-streaming.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Stop the streaming stack + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +cd "$SCRIPT_DIR" + +echo "==========================================" +echo "Stopping OBS Live Streaming Stack" +echo "==========================================" +echo "" + +# Stop HLS uploader +if [ -f "hls-uploader.pid" ]; then + PID=$(cat hls-uploader.pid) + if kill -0 $PID 2>/dev/null; then + echo "Stopping HLS uploader (PID: $PID)..." + kill $PID + rm hls-uploader.pid + echo "✓ HLS uploader stopped" + else + echo "HLS uploader not running" + rm hls-uploader.pid + fi +else + echo "No HLS uploader PID file found" +fi + +echo "" + +# Stop nginx-rtmp +echo "Stopping nginx-rtmp server..." +docker-compose down + +echo "" +echo "✓ Streaming stack stopped" +echo "" diff --git a/worker/admin.html b/worker/admin.html new file mode 100644 index 0000000..a98e481 --- /dev/null +++ b/worker/admin.html @@ -0,0 +1,1188 @@ + + + + + + Video Admin Panel + + + +
+
+

🎥 Video Admin Panel

+

Manage video visibility and sharing

+
+
+ + +
+
+ +
+
+
0
+
Total Videos
+
+
+
0
+
Private
+
+
+
0
+
Shareable
+
+
+
0
+
Clip Shareable
+
+
+ +
+
+ +
+
+
+
+

Loading videos...

+
+
+
+ +
+ + + + + + + + +
+
+ +
Video
+ +
+
+ + + + diff --git a/worker/video-server-enhanced.js b/worker/video-server-enhanced.js new file mode 100644 index 0000000..e1bdaad --- /dev/null +++ b/worker/video-server-enhanced.js @@ -0,0 +1,1055 @@ +/** + * Enhanced Cloudflare Worker for serving videos from R2 storage + * Features: + * - Admin panel with authentication + * - Video visibility controls (private, shareable, clip_shareable) + * - Clip generation and serving + * - Permission-based access control + */ + +// Admin login HTML +const LOGIN_HTML = ` + + + + + + Admin Login + + + + + + + +`; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + const path = url.pathname; + + // CORS headers + const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, HEAD, POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': '*', + }; + + if (request.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + // Admin routes + if (path.startsWith('/admin')) { + return await handleAdminRoutes(request, env, path, corsHeaders); + } + + // Clip serving + if (path.startsWith('/clip/')) { + return await handleClip(request, env, path, corsHeaders); + } + + // Root path and gallery (show HTML gallery of shareable videos) + if (path === '/' || path === '/gallery') { + return await handlePublicGallery(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders); + } + + // Video player page (/watch/filename) + if (path.startsWith('/watch/')) { + return await handleWatchPage(request, env, path, corsHeaders); + } + + // Public API (only lists shareable videos as JSON) + if (path === '/api/list') { + return await handlePublicList(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders); + } + + // Handle HLS live streams (/live/stream-name/file.m3u8 or file.ts) + if (path.startsWith('/live/')) { + return await handleHLSStream(request, env, path, corsHeaders); + } + + // Serve video file (with permission check) + const filename = path.substring(1); + if (filename) { + return await handleVideoFile(request, env, filename, corsHeaders); + } + + return new Response('Not found', { status: 404, headers: corsHeaders }); + + } catch (error) { + console.error('Error:', error); + return new Response(`Error: ${error.message}`, { status: 500, headers: corsHeaders }); + } + }, +}; + +/** + * Handle admin routes + */ +async function handleAdminRoutes(request, env, path, corsHeaders) { + // Login page + if (path === '/admin/login' && request.method === 'GET') { + return new Response(LOGIN_HTML, { + headers: { 'Content-Type': 'text/html; charset=utf-8' } + }); + } + + // Login POST + if (path === '/admin/login' && request.method === 'POST') { + const { password } = await request.json(); + const correctPassword = env.ADMIN_PASSWORD || 'changeme'; + + if (password === correctPassword) { + // Create session token + const token = await generateToken(password); + + return new Response(JSON.stringify({ success: true }), { + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': `admin_auth=${token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=86400` + } + }); + } + + return new Response(JSON.stringify({ error: 'Invalid password' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Check authentication for other admin routes + const isAuthenticated = await verifyAuth(request, env); + if (!isAuthenticated) { + return new Response('Unauthorized', { + status: 401, + headers: { 'Location': '/admin/login' } + }); + } + + // Admin panel + if (path === '/admin' || path === '/admin/') { + const adminHTML = await getAdminHTML(); + return new Response(adminHTML, { + headers: { 'Content-Type': 'text/html; charset=utf-8' } + }); + } + + // API: List all videos with metadata + if (path === '/admin/api/videos') { + return await handleAdminListVideos(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders); + } + + // API: Update video visibility + if (path === '/admin/api/videos/visibility' && request.method === 'POST') { + const { filename, visibility } = await request.json(); + await env.VIDEO_METADATA.put(filename, JSON.stringify({ visibility })); + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } + + // API: Delete video + if (path.startsWith('/admin/api/videos/') && request.method === 'DELETE') { + const filename = decodeURIComponent(path.replace('/admin/api/videos/', '')); + await env.R2_BUCKET.delete(filename); + await env.VIDEO_METADATA.delete(filename); + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } + + // API: Upload video + if (path === '/admin/api/upload' && request.method === 'POST') { + return await handleVideoUpload(request, env, corsHeaders); + } + + return new Response('Not found', { status: 404 }); +} + +/** + * Get admin HTML (reads from the admin.html file embedded as string or fetched) + */ +async function getAdminHTML() { + // In production, you'd embed this or fetch from R2 + // For now, we'll import it as a module or use a fetch + // Since we can't easily read files in Workers, we'll need to inline it or use a build step + + // For this implementation, we'll fetch it from a constant + // In production, use wrangler's module support or embed it + + return ` + +Admin Panel + +

Admin Panel

+

Please replace this with the full admin.html content using a build step or module import.

+

For now, use: /admin/api/videos to see the API.

+ + + `; + // TODO: In production, import admin.html content here +} + +/** + * List all videos for admin (includes metadata) + */ +async function handleAdminListVideos(bucket, kv, corsHeaders) { + const objects = await bucket.list(); + + const videos = await Promise.all( + objects.objects.map(async (obj) => { + const metadataStr = await kv.get(obj.key); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + + return { + name: obj.key, + size: obj.size, + uploaded: obj.uploaded, + visibility: metadata.visibility || 'shareable', + url: `/${obj.key}`, + }; + }) + ); + + return new Response(JSON.stringify({ count: videos.length, videos }), { + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); +} + +/** + * List public videos only (shareable ones) + */ +async function handlePublicList(bucket, kv, corsHeaders) { + const objects = await bucket.list(); + + const videos = await Promise.all( + objects.objects.map(async (obj) => { + const metadataStr = await kv.get(obj.key); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + return { + name: obj.key, + size: obj.size, + uploaded: obj.uploaded, + visibility: metadata.visibility || 'shareable', + url: `/${obj.key}`, + }; + }) + ); + + // Filter to only shareable videos + const shareableVideos = videos.filter(v => v.visibility === 'shareable'); + + return new Response(JSON.stringify({ count: shareableVideos.length, videos: shareableVideos }), { + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); +} + +/** + * Serve video file with permission check + */ +async function handleVideoFile(request, env, filename, corsHeaders) { + // Check permissions + const metadataStr = await env.VIDEO_METADATA.get(filename); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + const visibility = metadata.visibility || 'shareable'; + + // If private, require authentication + if (visibility === 'private') { + const isAuth = await verifyAuth(request, env); + if (!isAuth) { + return new Response('This video is private', { + status: 403, + headers: corsHeaders + }); + } + } + + // If clip_shareable, only allow clips + if (visibility === 'clip_shareable') { + const isAuth = await verifyAuth(request, env); + if (!isAuth) { + return new Response('Full video not available. Only clips can be shared.', { + status: 403, + headers: corsHeaders + }); + } + } + + // Serve the video + return await serveVideo(request, env.R2_BUCKET, filename, corsHeaders); +} + +/** + * Handle clip requests + */ +async function handleClip(request, env, path, corsHeaders) { + const filename = path.replace('/clip/', '').split('?')[0]; + const url = new URL(request.url); + const start = url.searchParams.get('start') || '0'; + const end = url.searchParams.get('end'); + + // Check permissions + const metadataStr = await env.VIDEO_METADATA.get(filename); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + const visibility = metadata.visibility || 'shareable'; + + // Clips are allowed for clip_shareable and shareable videos + if (visibility === 'private') { + const isAuth = await verifyAuth(request, env); + if (!isAuth) { + return new Response('This video is private', { + status: 403, + headers: corsHeaders + }); + } + } + + // For actual clip generation, you'd need to: + // 1. Use ffmpeg in a separate service, or + // 2. Use byte-range requests to approximate clips, or + // 3. Pre-generate clips on upload + + // For now, we'll serve with Content-Range headers as an approximation + // This won't give exact frame-accurate clips but will seek to the right position + + return new Response(` + + Video Clip + + + + + `, { + headers: { 'Content-Type': 'text/html; charset=utf-8' } + }); +} + +/** + * Serve video with range support + */ +async function serveVideo(request, bucket, filename, corsHeaders) { + const range = request.headers.get('Range'); + + let object; + if (range) { + const rangeMatch = range.match(/bytes=(\d+)-(\d*)/); + if (rangeMatch) { + const start = parseInt(rangeMatch[1]); + const end = rangeMatch[2] ? parseInt(rangeMatch[2]) : undefined; + + object = await bucket.get(filename, { + range: { offset: start, length: end ? end - start + 1 : undefined } + }); + } else { + object = await bucket.get(filename); + } + } else { + object = await bucket.get(filename); + } + + if (!object) { + return new Response('Video not found', { status: 404, headers: corsHeaders }); + } + + const contentType = getContentType(filename); + const headers = { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000', + 'Accept-Ranges': 'bytes', + ...corsHeaders, + }; + + if (range && object.range) { + headers['Content-Range'] = `bytes ${object.range.offset}-${object.range.offset + object.range.length - 1}/${object.size}`; + headers['Content-Length'] = object.range.length; + + return new Response(object.body, { status: 206, headers }); + } else { + headers['Content-Length'] = object.size; + return new Response(object.body, { status: 200, headers }); + } +} + +/** + * Public gallery (only shareable videos) + */ +async function handlePublicGallery(bucket, kv, corsHeaders) { + const objects = await bucket.list(); + + const videos = await Promise.all( + objects.objects.map(async (obj) => { + const metadataStr = await kv.get(obj.key); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + return { + ...obj, + visibility: metadata.visibility || 'shareable' + }; + }) + ); + + // Filter to only shareable videos AND exclude HLS live stream files + const validVideoExtensions = ['.mp4', '.mkv', '.mov', '.avi', '.webm', '.flv', '.wmv']; + const shareableVideos = videos.filter(v => { + // Must be shareable + if (v.visibility !== 'shareable') return false; + + // Exclude live streaming files + if (v.key.startsWith('live/')) return false; + + // Only include actual video files (not HLS chunks) + const ext = v.key.substring(v.key.lastIndexOf('.')).toLowerCase(); + return validVideoExtensions.includes(ext); + }); + + const videoItems = shareableVideos + .map(obj => { + const sizeInMB = (obj.size / (1024 * 1024)).toFixed(2); + const uploadDate = new Date(obj.uploaded).toLocaleDateString(); + const encodedKey = encodeURIComponent(obj.key); + + return ` +
+
+ +
+
+
+
+
+

${obj.key}

+

Size: ${sizeInMB} MB | Uploaded: ${uploadDate}

+
+ + +
+
+
+ `; + }) + .join('\n'); + + const emptyState = shareableVideos.length === 0 ? ` +
+
🎬
+

No Videos Available Yet

+

+ This gallery is currently empty. Check back soon for new content! +

+
+
📢
+

For Content Creators

+

+ Upload videos through the admin panel to make them available here. +

+
+
+ + ` : videoItems; + + const html = ` + + + + + Video Gallery + + + +
+

🎥 Video Gallery

+

${shareableVideos.length} video${shareableVideos.length === 1 ? '' : 's'} available

+
+
+ ${emptyState} +
+ + +`; + + return new Response(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8', ...corsHeaders } + }); +} + +/** + * Handle video upload from admin panel + */ +async function handleVideoUpload(request, env, corsHeaders) { + try { + // Parse multipart form data + const formData = await request.formData(); + const videoFile = formData.get('video'); + + if (!videoFile) { + return new Response(JSON.stringify({ error: 'No video file provided' }), { + status: 400, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } + + // Get filename + const filename = videoFile.name; + + // Validate file type + const validExtensions = ['.mp4', '.mkv', '.mov', '.avi', '.webm', '.flv', '.wmv']; + const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase(); + if (!validExtensions.includes(ext)) { + return new Response(JSON.stringify({ error: 'Invalid file type' }), { + status: 400, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } + + // Upload to R2 + await env.R2_BUCKET.put(filename, videoFile.stream(), { + httpMetadata: { + contentType: getContentType(filename) + } + }); + + // Set default visibility to shareable + await env.VIDEO_METADATA.put(filename, JSON.stringify({ + visibility: 'shareable', + uploadedAt: new Date().toISOString(), + uploadMethod: 'admin_panel' + })); + + return new Response(JSON.stringify({ + success: true, + filename: filename, + url: `/${filename}` + }), { + status: 200, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + + } catch (error) { + console.error('Upload error:', error); + return new Response(JSON.stringify({ + error: 'Upload failed', + details: error.message + }), { + status: 500, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } +} + +/** + * Verify admin authentication + */ +async function verifyAuth(request, env) { + const cookie = request.headers.get('Cookie') || ''; + const match = cookie.match(/admin_auth=([^;]+)/); + + if (!match) return false; + + const token = match[1]; + const correctPassword = env.ADMIN_PASSWORD || 'changeme'; + const expectedToken = await generateToken(correctPassword); + + return token === expectedToken; +} + +/** + * Generate auth token + */ +async function generateToken(password) { + const encoder = new TextEncoder(); + const data = encoder.encode(password + 'salt123'); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Handle HLS live stream requests + */ +async function handleHLSStream(request, env, path, corsHeaders) { + // Path format: /live/stream-name/file.m3u8 or /live/stream-name/file.ts + const filename = path.substring(1); // Remove leading slash + + try { + const object = await env.R2_BUCKET.get(filename); + + if (!object) { + return new Response('Stream not found', { + status: 404, + headers: corsHeaders + }); + } + + // Determine content type based on extension + const contentType = filename.endsWith('.m3u8') + ? 'application/vnd.apple.mpegurl' + : filename.endsWith('.ts') + ? 'video/MP2T' + : 'application/octet-stream'; + + // Set appropriate caching headers + const cacheControl = filename.endsWith('.m3u8') + ? 'no-cache, no-store, must-revalidate' // Playlist changes frequently + : 'public, max-age=31536000'; // Chunks are immutable + + const headers = { + 'Content-Type': contentType, + 'Cache-Control': cacheControl, + ...corsHeaders, + }; + + return new Response(object.body, { headers }); + + } catch (error) { + console.error('Error serving HLS stream:', error); + return new Response('Error serving stream', { + status: 500, + headers: corsHeaders + }); + } +} + +/** + * Handle watch page (dedicated video player) + */ +async function handleWatchPage(request, env, path, corsHeaders) { + const filename = decodeURIComponent(path.replace('/watch/', '')); + + // Check if video exists and get metadata + const metadataStr = await env.VIDEO_METADATA.get(filename); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + const visibility = metadata.visibility || 'shareable'; + + // Check permissions + if (visibility === 'private' || visibility === 'clip_shareable') { + const isAuth = await verifyAuth(request, env); + if (!isAuth) { + return new Response('This video is not available', { + status: 403, + headers: corsHeaders + }); + } + } + + // Get video object to check if it exists + const videoObject = await env.R2_BUCKET.head(filename); + if (!videoObject) { + return new Response('Video not found', { + status: 404, + headers: corsHeaders + }); + } + + const sizeInMB = (videoObject.size / (1024 * 1024)).toFixed(2); + const uploadDate = new Date(videoObject.uploaded).toLocaleDateString(); + + const html = ` + + + + + ${filename} + + + +
+
+ ← Gallery +

${filename}

+
+
+ +
+ +
+ +
+
+
+
+ Size: + ${sizeInMB} MB +
+
+ Uploaded: + ${uploadDate} +
+
+ Format: + ${filename.split('.').pop().toUpperCase()} +
+
+ +
+ + ⬇ Download +
+
+
+ + + +`; + + return new Response(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8', ...corsHeaders } + }); +} + +/** + * Get content type + */ +function getContentType(filename) { + const ext = filename.split('.').pop().toLowerCase(); + const types = { + 'mp4': 'video/mp4', + 'mkv': 'video/x-matroska', + 'mov': 'video/quicktime', + 'avi': 'video/x-msvideo', + 'webm': 'video/webm', + 'flv': 'video/x-flv', + 'wmv': 'video/x-ms-wmv', + 'm3u8': 'application/vnd.apple.mpegurl', + 'ts': 'video/MP2T', + }; + return types[ext] || 'application/octet-stream'; +} diff --git a/worker/video-server-final.js b/worker/video-server-final.js new file mode 100644 index 0000000..1c3ce1c --- /dev/null +++ b/worker/video-server-final.js @@ -0,0 +1,1785 @@ +/** + * Enhanced Cloudflare Worker for serving videos from R2 storage + * Features: + * - Admin panel with authentication + * - Video visibility controls (private, shareable, clip_shareable) + * - Clip generation and serving + * - Permission-based access control + */ + +const ADMIN_HTML = ` + + + + + + Video Admin Panel + + + +
+
+

🎥 Video Admin Panel

+

Manage video visibility and sharing

+
+
+ + +
+
+ +
+
+
0
+
Total Videos
+
+
+
0
+
Private
+
+
+
0
+
Shareable
+
+
+
0
+
Clip Shareable
+
+
+ +
+
+ +
+
+
+
+

Loading videos...

+
+
+
+ +
+ + + + + + + + +
+
+ +
Video
+ +
+
+ + + + +`; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + const path = url.pathname; + + // CORS headers + const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, HEAD, POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': '*', + }; + + if (request.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + // Admin routes + if (path.startsWith('/admin')) { + return await handleAdminRoutes(request, env, path, corsHeaders); + } + + // Clip serving + if (path.startsWith('/clip/')) { + return await handleClip(request, env, path, corsHeaders); + } + + // Public gallery (only shows shareable videos) + if (path === '/gallery') { + return await handlePublicGallery(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders); + } + + // Public API (only lists shareable videos) + if (path === '/' || path === '/api/list') { + return await handlePublicList(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders); + } + + // Serve video file (with permission check) + const filename = path.substring(1); + if (filename) { + return await handleVideoFile(request, env, filename, corsHeaders); + } + + return new Response('Not found', { status: 404, headers: corsHeaders }); + + } catch (error) { + console.error('Error:', error); + return new Response(`Error: ${error.message}`, { status: 500, headers: corsHeaders }); + } + }, +}; + +/** + * Handle admin routes + */ +async function handleAdminRoutes(request, env, path, corsHeaders) { + // Login page + if (path === '/admin/login' && request.method === 'GET') { + return new Response(LOGIN_HTML, { + headers: { 'Content-Type': 'text/html; charset=utf-8' } + }); + } + + // Login POST + if (path === '/admin/login' && request.method === 'POST') { + const { password } = await request.json(); + const correctPassword = env.ADMIN_PASSWORD || 'changeme'; + + if (password === correctPassword) { + // Create session token + const token = await generateToken(password); + + return new Response(JSON.stringify({ success: true }), { + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': `admin_auth=${token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=86400` + } + }); + } + + return new Response(JSON.stringify({ error: 'Invalid password' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Check authentication for other admin routes + const isAuthenticated = await verifyAuth(request, env); + if (!isAuthenticated) { + return new Response('Unauthorized', { + status: 401, + headers: { 'Location': '/admin/login' } + }); + } + + // Admin panel + if (path === '/admin' || path === '/admin/') { + const adminHTML = await getAdminHTML(); + return new Response(adminHTML, { + headers: { 'Content-Type': 'text/html; charset=utf-8' } + }); + } + + // API: List all videos with metadata + if (path === '/admin/api/videos') { + return await handleAdminListVideos(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders); + } + + // API: Update video visibility + if (path === '/admin/api/videos/visibility' && request.method === 'POST') { + const { filename, visibility } = await request.json(); + await env.VIDEO_METADATA.put(filename, JSON.stringify({ visibility })); + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } + + // API: Delete video + if (path.startsWith('/admin/api/videos/') && request.method === 'DELETE') { + const filename = decodeURIComponent(path.replace('/admin/api/videos/', '')); + await env.R2_BUCKET.delete(filename); + await env.VIDEO_METADATA.delete(filename); + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } + + // API: Upload video + if (path === '/admin/api/upload' && request.method === 'POST') { + return await handleVideoUpload(request, env, corsHeaders); + } + + return new Response('Not found', { status: 404 }); +} + +/** + * Get admin HTML (reads from the admin.html file embedded as string or fetched) + */ +async function getAdminHTML() { return ADMIN_HTML; } // OLD VERSION: { + // In production, you'd embed this or fetch from R2 + // For now, we'll import it as a module or use a fetch + // Since we can't easily read files in Workers, we'll need to inline it or use a build step + + // For this implementation, we'll fetch it from a constant + // In production, use wrangler's module support or embed it + + return ` + +Admin Panel + +

Admin Panel

+

Please replace this with the full admin.html content using a build step or module import.

+

For now, use: /admin/api/videos to see the API.

+ + + `; + // TODO: In production, import admin.html content here +} + +/** + * List all videos for admin (includes metadata) + */ +async function handleAdminListVideos(bucket, kv, corsHeaders) { + const objects = await bucket.list(); + + const videos = await Promise.all( + objects.objects.map(async (obj) => { + const metadataStr = await kv.get(obj.key); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + + return { + name: obj.key, + size: obj.size, + uploaded: obj.uploaded, + visibility: metadata.visibility || 'shareable', + url: `/${obj.key}`, + }; + }) + ); + + return new Response(JSON.stringify({ count: videos.length, videos }), { + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); +} + +/** + * List public videos only (shareable ones) + */ +async function handlePublicList(bucket, kv, corsHeaders) { + const objects = await bucket.list(); + + const videos = await Promise.all( + objects.objects.map(async (obj) => { + const metadataStr = await kv.get(obj.key); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + return { + name: obj.key, + size: obj.size, + uploaded: obj.uploaded, + visibility: metadata.visibility || 'shareable', + url: `/${obj.key}`, + }; + }) + ); + + // Filter to only shareable videos + const shareableVideos = videos.filter(v => v.visibility === 'shareable'); + + return new Response(JSON.stringify({ count: shareableVideos.length, videos: shareableVideos }), { + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); +} + +/** + * Serve video file with permission check + */ +async function handleVideoFile(request, env, filename, corsHeaders) { + // Check permissions + const metadataStr = await env.VIDEO_METADATA.get(filename); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + const visibility = metadata.visibility || 'shareable'; + + // If private, require authentication + if (visibility === 'private') { + const isAuth = await verifyAuth(request, env); + if (!isAuth) { + return new Response('This video is private', { + status: 403, + headers: corsHeaders + }); + } + } + + // If clip_shareable, only allow clips + if (visibility === 'clip_shareable') { + const isAuth = await verifyAuth(request, env); + if (!isAuth) { + return new Response('Full video not available. Only clips can be shared.', { + status: 403, + headers: corsHeaders + }); + } + } + + // Serve the video + return await serveVideo(request, env.R2_BUCKET, filename, corsHeaders); +} + +/** + * Handle clip requests + */ +async function handleClip(request, env, path, corsHeaders) { + const filename = path.replace('/clip/', '').split('?')[0]; + const url = new URL(request.url); + const start = url.searchParams.get('start') || '0'; + const end = url.searchParams.get('end'); + + // Check permissions + const metadataStr = await env.VIDEO_METADATA.get(filename); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + const visibility = metadata.visibility || 'shareable'; + + // Clips are allowed for clip_shareable and shareable videos + if (visibility === 'private') { + const isAuth = await verifyAuth(request, env); + if (!isAuth) { + return new Response('This video is private', { + status: 403, + headers: corsHeaders + }); + } + } + + // For actual clip generation, you'd need to: + // 1. Use ffmpeg in a separate service, or + // 2. Use byte-range requests to approximate clips, or + // 3. Pre-generate clips on upload + + // For now, we'll serve with Content-Range headers as an approximation + // This won't give exact frame-accurate clips but will seek to the right position + + return new Response(` + + Video Clip + + + + + `, { + headers: { 'Content-Type': 'text/html; charset=utf-8' } + }); +} + +/** + * Serve video with range support + */ +async function serveVideo(request, bucket, filename, corsHeaders) { + const range = request.headers.get('Range'); + + let object; + if (range) { + const rangeMatch = range.match(/bytes=(\d+)-(\d*)/); + if (rangeMatch) { + const start = parseInt(rangeMatch[1]); + const end = rangeMatch[2] ? parseInt(rangeMatch[2]) : undefined; + + object = await bucket.get(filename, { + range: { offset: start, length: end ? end - start + 1 : undefined } + }); + } else { + object = await bucket.get(filename); + } + } else { + object = await bucket.get(filename); + } + + if (!object) { + return new Response('Video not found', { status: 404, headers: corsHeaders }); + } + + const contentType = getContentType(filename); + const headers = { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000', + 'Accept-Ranges': 'bytes', + ...corsHeaders, + }; + + if (range && object.range) { + headers['Content-Range'] = `bytes ${object.range.offset}-${object.range.offset + object.range.length - 1}/${object.size}`; + headers['Content-Length'] = object.range.length; + + return new Response(object.body, { status: 206, headers }); + } else { + headers['Content-Length'] = object.size; + return new Response(object.body, { status: 200, headers }); + } +} + +/** + * Public gallery (only shareable videos) + */ +async function handlePublicGallery(bucket, kv, corsHeaders) { + const objects = await bucket.list(); + + const videos = await Promise.all( + objects.objects.map(async (obj) => { + const metadataStr = await kv.get(obj.key); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + return { + ...obj, + visibility: metadata.visibility || 'shareable' + }; + }) + ); + + const shareableVideos = videos.filter(v => v.visibility === 'shareable'); + + const videoItems = shareableVideos + .map(obj => { + const sizeInMB = (obj.size / (1024 * 1024)).toFixed(2); + const uploadDate = new Date(obj.uploaded).toLocaleDateString(); + + return ` +
+ +
+

${obj.key}

+

Size: ${sizeInMB} MB | Uploaded: ${uploadDate}

+ +
+
+ `; + }) + .join('\n'); + + const emptyState = shareableVideos.length === 0 ? ` +
+
🎬
+

No Videos Available Yet

+

+ This gallery is currently empty. Check back soon for new content! +

+
+
📢
+

For Content Creators

+

+ Upload videos through the admin panel to make them available here. +

+
+
+ + ` : videoItems; + + const html = ` + + + + + Video Gallery + + + +
+

🎥 Video Gallery

+

${shareableVideos.length} video${shareableVideos.length === 1 ? '' : 's'} available

+
+
+ ${emptyState} +
+ + +`; + + return new Response(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8', ...corsHeaders } + }); +} + +/** + * Handle video upload from admin panel + */ +async function handleVideoUpload(request, env, corsHeaders) { + try { + // Parse multipart form data + const formData = await request.formData(); + const videoFile = formData.get('video'); + + if (!videoFile) { + return new Response(JSON.stringify({ error: 'No video file provided' }), { + status: 400, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } + + // Get filename + const filename = videoFile.name; + + // Validate file type + const validExtensions = ['.mp4', '.mkv', '.mov', '.avi', '.webm', '.flv', '.wmv']; + const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase(); + if (!validExtensions.includes(ext)) { + return new Response(JSON.stringify({ error: 'Invalid file type' }), { + status: 400, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } + + // Upload to R2 + await env.R2_BUCKET.put(filename, videoFile.stream(), { + httpMetadata: { + contentType: getContentType(filename) + } + }); + + // Set default visibility to shareable + await env.VIDEO_METADATA.put(filename, JSON.stringify({ + visibility: 'shareable', + uploadedAt: new Date().toISOString(), + uploadMethod: 'admin_panel' + })); + + return new Response(JSON.stringify({ + success: true, + filename: filename, + url: `/${filename}` + }), { + status: 200, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + + } catch (error) { + console.error('Upload error:', error); + return new Response(JSON.stringify({ + error: 'Upload failed', + details: error.message + }), { + status: 500, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } +} + +/** + * Verify admin authentication + */ +async function verifyAuth(request, env) { + const cookie = request.headers.get('Cookie') || ''; + const match = cookie.match(/admin_auth=([^;]+)/); + + if (!match) return false; + + const token = match[1]; + const correctPassword = env.ADMIN_PASSWORD || 'changeme'; + const expectedToken = await generateToken(correctPassword); + + return token === expectedToken; +} + +/** + * Generate auth token + */ +async function generateToken(password) { + const encoder = new TextEncoder(); + const data = encoder.encode(password + 'salt123'); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Get content type + */ +function getContentType(filename) { + const ext = filename.split('.').pop().toLowerCase(); + const types = { + 'mp4': 'video/mp4', + 'mkv': 'video/x-matroska', + 'mov': 'video/quicktime', + 'avi': 'video/x-msvideo', + 'webm': 'video/webm', + 'flv': 'video/x-flv', + 'wmv': 'video/x-ms-wmv', + }; + return types[ext] || 'application/octet-stream'; +} diff --git a/worker/video-server.js b/worker/video-server.js new file mode 100644 index 0000000..faf6db7 --- /dev/null +++ b/worker/video-server.js @@ -0,0 +1,1941 @@ +/** + * Enhanced Cloudflare Worker for serving videos from R2 storage + * Features: + * - Admin panel with authentication + * - Video visibility controls (private, shareable, clip_shareable) + * - Clip generation and serving + * - Permission-based access control + */ + +// Admin login HTML +const LOGIN_HTML = ` + + + + + + Admin Login + + + + + + + +`; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + const path = url.pathname; + + // CORS headers + const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, HEAD, POST, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': '*', + }; + + if (request.method === 'OPTIONS') { + return new Response(null, { headers: corsHeaders }); + } + + try { + // Admin routes + if (path.startsWith('/admin')) { + return await handleAdminRoutes(request, env, path, corsHeaders); + } + + // Clip serving + if (path.startsWith('/clip/')) { + return await handleClip(request, env, path, corsHeaders); + } + + // Public gallery (only shows shareable videos) + if (path === '/gallery') { + return await handlePublicGallery(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders); + } + + // Public API (only lists shareable videos) + if (path === '/' || path === '/api/list') { + return await handlePublicList(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders); + } + + // Handle HLS live streams (/live/stream-name/file.m3u8 or file.ts) + if (path.startsWith('/live/')) { + return await handleHLSStream(request, env, path, corsHeaders); + } + + // Serve video file (with permission check) + const filename = path.substring(1); + if (filename) { + return await handleVideoFile(request, env, filename, corsHeaders); + } + + return new Response('Not found', { status: 404, headers: corsHeaders }); + + } catch (error) { + console.error('Error:', error); + return new Response(`Error: ${error.message}`, { status: 500, headers: corsHeaders }); + } + }, +}; + +/** + * Handle admin routes + */ +async function handleAdminRoutes(request, env, path, corsHeaders) { + // Login page + if (path === '/admin/login' && request.method === 'GET') { + return new Response(LOGIN_HTML, { + headers: { 'Content-Type': 'text/html; charset=utf-8' } + }); + } + + // Login POST + if (path === '/admin/login' && request.method === 'POST') { + const { password } = await request.json(); + const correctPassword = env.ADMIN_PASSWORD || 'changeme'; + + if (password === correctPassword) { + // Create session token + const token = await generateToken(password); + + return new Response(JSON.stringify({ success: true }), { + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': `admin_auth=${token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=86400` + } + }); + } + + return new Response(JSON.stringify({ error: 'Invalid password' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Check authentication for other admin routes + const isAuthenticated = await verifyAuth(request, env); + if (!isAuthenticated) { + return new Response('Unauthorized', { + status: 401, + headers: { 'Location': '/admin/login' } + }); + } + + // Admin panel + if (path === '/admin' || path === '/admin/') { + const adminHTML = await getAdminHTML(); + return new Response(adminHTML, { + headers: { 'Content-Type': 'text/html; charset=utf-8' } + }); + } + + // API: List all videos with metadata + if (path === '/admin/api/videos') { + return await handleAdminListVideos(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders); + } + + // API: Update video visibility + if (path === '/admin/api/videos/visibility' && request.method === 'POST') { + const { filename, visibility } = await request.json(); + await env.VIDEO_METADATA.put(filename, JSON.stringify({ visibility })); + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } + + // API: Delete video + if (path.startsWith('/admin/api/videos/') && request.method === 'DELETE') { + const filename = decodeURIComponent(path.replace('/admin/api/videos/', '')); + await env.R2_BUCKET.delete(filename); + await env.VIDEO_METADATA.delete(filename); + + return new Response(JSON.stringify({ success: true }), { + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } + + // API: Upload video + if (path === '/admin/api/upload' && request.method === 'POST') { + return await handleVideoUpload(request, env, corsHeaders); + } + + return new Response('Not found', { status: 404 }); +} + +/** + * Get admin HTML (reads from the admin.html file embedded as string or fetched) + */ +async function getAdminHTML() { + return ` + + + + + Video Admin Panel + + + +
+
+

🎥 Video Admin Panel

+

Manage video visibility and sharing

+
+
+ + +
+
+ +
+
+
0
+
Total Videos
+
+
+
0
+
Private
+
+
+
0
+
Shareable
+
+
+
0
+
Clip Shareable
+
+
+ +
+
+ +
+
+
+
+

Loading videos...

+
+
+
+ +
+ + + + + + + + +
+
+ +
Video
+ +
+
+ + + + +`; +} + +/** + * List all videos for admin (includes metadata) + */ +async function handleAdminListVideos(bucket, kv, corsHeaders) { + const objects = await bucket.list(); + + const videos = await Promise.all( + objects.objects.map(async (obj) => { + const metadataStr = await kv.get(obj.key); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + + return { + name: obj.key, + size: obj.size, + uploaded: obj.uploaded, + visibility: metadata.visibility || 'shareable', + url: `/${obj.key}`, + }; + }) + ); + + return new Response(JSON.stringify({ count: videos.length, videos }), { + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); +} + +/** + * List public videos only (shareable ones) + */ +async function handlePublicList(bucket, kv, corsHeaders) { + const objects = await bucket.list(); + + const videos = await Promise.all( + objects.objects.map(async (obj) => { + const metadataStr = await kv.get(obj.key); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + return { + name: obj.key, + size: obj.size, + uploaded: obj.uploaded, + visibility: metadata.visibility || 'shareable', + url: `/${obj.key}`, + }; + }) + ); + + // Filter to only shareable videos + const shareableVideos = videos.filter(v => v.visibility === 'shareable'); + + return new Response(JSON.stringify({ count: shareableVideos.length, videos: shareableVideos }), { + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); +} + +/** + * Serve video file with permission check + */ +async function handleVideoFile(request, env, filename, corsHeaders) { + // Check permissions + const metadataStr = await env.VIDEO_METADATA.get(filename); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + const visibility = metadata.visibility || 'shareable'; + + // If private, require authentication + if (visibility === 'private') { + const isAuth = await verifyAuth(request, env); + if (!isAuth) { + return new Response('This video is private', { + status: 403, + headers: corsHeaders + }); + } + } + + // If clip_shareable, only allow clips + if (visibility === 'clip_shareable') { + const isAuth = await verifyAuth(request, env); + if (!isAuth) { + return new Response('Full video not available. Only clips can be shared.', { + status: 403, + headers: corsHeaders + }); + } + } + + // Serve the video + return await serveVideo(request, env.R2_BUCKET, filename, corsHeaders); +} + +/** + * Handle clip requests + */ +async function handleClip(request, env, path, corsHeaders) { + const filename = path.replace('/clip/', '').split('?')[0]; + const url = new URL(request.url); + const start = url.searchParams.get('start') || '0'; + const end = url.searchParams.get('end'); + + // Check permissions + const metadataStr = await env.VIDEO_METADATA.get(filename); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + const visibility = metadata.visibility || 'shareable'; + + // Clips are allowed for clip_shareable and shareable videos + if (visibility === 'private') { + const isAuth = await verifyAuth(request, env); + if (!isAuth) { + return new Response('This video is private', { + status: 403, + headers: corsHeaders + }); + } + } + + // For actual clip generation, you'd need to: + // 1. Use ffmpeg in a separate service, or + // 2. Use byte-range requests to approximate clips, or + // 3. Pre-generate clips on upload + + // For now, we'll serve with Content-Range headers as an approximation + // This won't give exact frame-accurate clips but will seek to the right position + + return new Response(` + + Video Clip + + + + + `, { + headers: { 'Content-Type': 'text/html; charset=utf-8' } + }); +} + +/** + * Serve video with range support + */ +async function serveVideo(request, bucket, filename, corsHeaders) { + const range = request.headers.get('Range'); + + let object; + if (range) { + const rangeMatch = range.match(/bytes=(\d+)-(\d*)/); + if (rangeMatch) { + const start = parseInt(rangeMatch[1]); + const end = rangeMatch[2] ? parseInt(rangeMatch[2]) : undefined; + + object = await bucket.get(filename, { + range: { offset: start, length: end ? end - start + 1 : undefined } + }); + } else { + object = await bucket.get(filename); + } + } else { + object = await bucket.get(filename); + } + + if (!object) { + return new Response('Video not found', { status: 404, headers: corsHeaders }); + } + + const contentType = getContentType(filename); + const headers = { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000', + 'Accept-Ranges': 'bytes', + ...corsHeaders, + }; + + if (range && object.range) { + headers['Content-Range'] = `bytes ${object.range.offset}-${object.range.offset + object.range.length - 1}/${object.size}`; + headers['Content-Length'] = object.range.length; + + return new Response(object.body, { status: 206, headers }); + } else { + headers['Content-Length'] = object.size; + return new Response(object.body, { status: 200, headers }); + } +} + +/** + * Public gallery (only shareable videos) + */ +async function handlePublicGallery(bucket, kv, corsHeaders) { + const objects = await bucket.list(); + + const videos = await Promise.all( + objects.objects.map(async (obj) => { + const metadataStr = await kv.get(obj.key); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + return { + ...obj, + visibility: metadata.visibility || 'shareable' + }; + }) + ); + + const shareableVideos = videos.filter(v => v.visibility === 'shareable'); + + const videoItems = shareableVideos + .map(obj => { + const sizeInMB = (obj.size / (1024 * 1024)).toFixed(2); + const uploadDate = new Date(obj.uploaded).toLocaleDateString(); + + return ` +
+ +
+

${obj.key}

+

Size: ${sizeInMB} MB | Uploaded: ${uploadDate}

+ +
+
+ `; + }) + .join('\n'); + + const emptyState = shareableVideos.length === 0 ? ` +
+
🎬
+

No Videos Available Yet

+

+ This gallery is currently empty. Check back soon for new content! +

+
+
📢
+

For Content Creators

+

+ Upload videos through the admin panel to make them available here. +

+
+
+ + ` : videoItems; + + const html = ` + + + + + Video Gallery + + + +
+

🎥 Video Gallery

+

${shareableVideos.length} video${shareableVideos.length === 1 ? '' : 's'} available

+
+
+ ${emptyState} +
+ + +`; + + return new Response(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8', ...corsHeaders } + }); +} + +/** + * Handle video upload from admin panel + */ +async function handleVideoUpload(request, env, corsHeaders) { + try { + // Parse multipart form data + const formData = await request.formData(); + const videoFile = formData.get('video'); + + if (!videoFile) { + return new Response(JSON.stringify({ error: 'No video file provided' }), { + status: 400, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } + + // Get filename + const filename = videoFile.name; + + // Validate file type + const validExtensions = ['.mp4', '.mkv', '.mov', '.avi', '.webm', '.flv', '.wmv']; + const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase(); + if (!validExtensions.includes(ext)) { + return new Response(JSON.stringify({ error: 'Invalid file type' }), { + status: 400, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } + + // Upload to R2 + await env.R2_BUCKET.put(filename, videoFile.stream(), { + httpMetadata: { + contentType: getContentType(filename) + } + }); + + // Set default visibility to shareable + await env.VIDEO_METADATA.put(filename, JSON.stringify({ + visibility: 'shareable', + uploadedAt: new Date().toISOString(), + uploadMethod: 'admin_panel' + })); + + return new Response(JSON.stringify({ + success: true, + filename: filename, + url: `/${filename}` + }), { + status: 200, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + + } catch (error) { + console.error('Upload error:', error); + return new Response(JSON.stringify({ + error: 'Upload failed', + details: error.message + }), { + status: 500, + headers: { 'Content-Type': 'application/json', ...corsHeaders } + }); + } +} + +/** + * Verify admin authentication + */ +async function verifyAuth(request, env) { + const cookie = request.headers.get('Cookie') || ''; + const match = cookie.match(/admin_auth=([^;]+)/); + + if (!match) return false; + + const token = match[1]; + const correctPassword = env.ADMIN_PASSWORD || 'changeme'; + const expectedToken = await generateToken(correctPassword); + + return token === expectedToken; +} + +/** + * Generate auth token + */ +async function generateToken(password) { + const encoder = new TextEncoder(); + const data = encoder.encode(password + 'salt123'); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Handle HLS live stream requests + */ +async function handleHLSStream(request, env, path, corsHeaders) { + // Path format: /live/stream-name/file.m3u8 or /live/stream-name/file.ts + const filename = path.substring(1); // Remove leading slash + + try { + const object = await env.R2_BUCKET.get(filename); + + if (!object) { + return new Response('Stream not found', { + status: 404, + headers: corsHeaders + }); + } + + // Determine content type based on extension + const contentType = filename.endsWith('.m3u8') + ? 'application/vnd.apple.mpegurl' + : filename.endsWith('.ts') + ? 'video/MP2T' + : 'application/octet-stream'; + + // Set appropriate caching headers + const cacheControl = filename.endsWith('.m3u8') + ? 'no-cache, no-store, must-revalidate' // Playlist changes frequently + : 'public, max-age=31536000'; // Chunks are immutable + + const headers = { + 'Content-Type': contentType, + 'Cache-Control': cacheControl, + ...corsHeaders, + }; + + return new Response(object.body, { headers }); + + } catch (error) { + console.error('Error serving HLS stream:', error); + return new Response('Error serving stream', { + status: 500, + headers: corsHeaders + }); + } +} + +/** + * Get content type + */ +function getContentType(filename) { + const ext = filename.split('.').pop().toLowerCase(); + const types = { + 'mp4': 'video/mp4', + 'mkv': 'video/x-matroska', + 'mov': 'video/quicktime', + 'avi': 'video/x-msvideo', + 'webm': 'video/webm', + 'flv': 'video/x-flv', + 'wmv': 'video/x-ms-wmv', + 'm3u8': 'application/vnd.apple.mpegurl', + 'ts': 'video/MP2T', + }; + return types[ext] || 'application/octet-stream'; +} diff --git a/worker/wrangler-enhanced.toml b/worker/wrangler-enhanced.toml new file mode 100644 index 0000000..390a151 --- /dev/null +++ b/worker/wrangler-enhanced.toml @@ -0,0 +1,29 @@ +name = "obs-video-server" +main = "video-server-enhanced.js" +compatibility_date = "2024-01-01" + +# R2 bucket binding +[[r2_buckets]] +binding = "R2_BUCKET" +bucket_name = "obs-videos" + +# KV namespace for video metadata (visibility settings) +[[kv_namespaces]] +binding = "VIDEO_METADATA" +id = "placeholder_id" # Will be replaced after creation + +# Environment variables +[vars] +# These can be overridden via wrangler secret +# ADMIN_PASSWORD will be set via: wrangler secret put ADMIN_PASSWORD + +# Production environment +[env.production] +# Uncomment after setting up custom domain +# routes = [ +# { pattern = "videos.jeffemmett.com/*", custom_domain = true } +# ] + +# Development environment +[env.development] +# Use different KV namespace for development if needed diff --git a/worker/wrangler-simple.toml b/worker/wrangler-simple.toml new file mode 100644 index 0000000..cdff162 --- /dev/null +++ b/worker/wrangler-simple.toml @@ -0,0 +1,19 @@ +name = "obs-video-server" +main = "video-server.js" +compatibility_date = "2024-01-01" + +# R2 bucket binding +[[r2_buckets]] +binding = "R2_BUCKET" +bucket_name = "obs-videos" + +# KV namespace for video metadata (visibility settings) +[[kv_namespaces]] +binding = "VIDEO_METADATA" +id = "placeholder_id" # Replace with your KV namespace ID + +# Custom domain (add after KV setup) +# Uncomment these lines and redeploy to use custom domain: +# routes = [ +# { pattern = "videos.jeffemmett.com/*", custom_domain = true } +# ] diff --git a/worker/wrangler.toml b/worker/wrangler.toml new file mode 100644 index 0000000..95aba99 --- /dev/null +++ b/worker/wrangler.toml @@ -0,0 +1,34 @@ +name = "obs-video-server" +main = "video-server-enhanced.js" +compatibility_date = "2024-01-01" + +# R2 bucket binding +[[r2_buckets]] +binding = "R2_BUCKET" +bucket_name = "obs-videos" + +# KV namespace for video metadata (visibility settings) +[[kv_namespaces]] +binding = "VIDEO_METADATA" +id = "6fc255b8c813485c9dfe6e8f1514d667" + +# KV namespace for user data (authentication, saved videos, playlists) +[[kv_namespaces]] +binding = "USER_DATA" +id = "00f9fb8952674f3a9dac25a700d3cfda" + +# Environment variables +[vars] +# These can be overridden via wrangler secret +# ADMIN_PASSWORD will be set via: wrangler secret put ADMIN_PASSWORD + +# Production environment +[env.production] +# Uncomment after setting up custom domain +# routes = [ +# { pattern = "videos.jeffemmett.com/*", custom_domain = true } +# ] + +# Development environment +[env.development] +# Use different KV namespace for development if needed