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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-11-25 03:37:35 -08:00
commit 76b9485d2c
49 changed files with 10325 additions and 0 deletions

30
.dockerignore Normal file
View File

@ -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

17
.env.example Normal file
View File

@ -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 <account-id> with your actual account ID)
R2_ENDPOINT=https://<account-id>.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

47
.gitignore vendored Normal file
View File

@ -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

317
ADMIN.md Normal file
View File

@ -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

139
ADMIN_QUICKSTART.md Normal file
View File

@ -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

191
DEPLOY_UPDATES.md Normal file
View File

@ -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

27
Dockerfile Normal file
View File

@ -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"]

21
LICENSE Normal file
View File

@ -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.

120
OBS_SETUP.md Normal file
View File

@ -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

165
QUICK_SETUP.md Normal file
View File

@ -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
```

293
README.md Normal file
View File

@ -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

397
SETUP.md Normal file
View File

@ -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`.

220
UPLOAD_FEATURE.md Normal file
View File

@ -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

10
cors-config.json Normal file
View File

@ -0,0 +1,10 @@
{
"CORSRules": [
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3600
}
]
}

21
docker-compose.yml Normal file
View File

@ -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

244
obs_scripts/auto_upload.py Normal file
View File

@ -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 """<h2>OBS R2 Auto-Upload</h2>
<p>Automatically uploads completed recordings to Cloudflare R2.</p>
<p><b>Setup:</b></p>
<ol>
<li>Set the project root directory (where .env file is located)</li>
<li>Ensure Python dependencies are installed</li>
<li>Configure .env with R2 credentials</li>
<li>Enable the script</li>
</ol>
<p>When you stop recording, the video will be automatically uploaded.</p>
"""
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.

3
obs_uploader/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""OBS to R2 Uploader - Upload OBS recordings to Cloudflare R2 storage."""
__version__ = "1.0.0"

78
obs_uploader/config.py Normal file
View File

@ -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}"

View File

@ -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 <watch_directory>")
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()

View File

@ -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 <hls_directory>")
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()

310
obs_uploader/upload.py Normal file
View File

@ -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 <video_file> [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()

20
package.json Normal file
View File

@ -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"
}

5
requirements.txt Normal file
View File

@ -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

62
scripts/build-admin.sh Executable file
View File

@ -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 ""

114
scripts/build-worker.py Executable file
View File

@ -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 `
<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Admin Panel</title></head>
<body>
<h1>Admin Panel</h1>
<p>Please replace this with the full admin.html content using a build step or module import.</p>
<p>For now, use: <a href="/admin/api/videos">/admin/api/videos</a> to see the API.</p>
</body>
</html>
`;
// 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()

108
scripts/deploy.sh Executable file
View File

@ -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 ""

89
scripts/setup-admin.sh Executable file
View File

@ -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 ""

97
scripts/setup.sh Executable file
View File

@ -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 ""

19
scripts/start-watcher.bat Normal file
View File

@ -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"
)

26
scripts/start-watcher.sh Executable file
View File

@ -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

46
scripts/test-upload.sh Executable file
View File

@ -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 "=================================================="

13
scripts/upload.bat Normal file
View File

@ -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 %*

20
scripts/upload.sh Executable file
View File

@ -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 "$@"

21
start-obs-watcher.sh Executable file
View File

@ -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

View File

@ -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
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
</head>
<body>
<video id="video" controls style="width: 100%; max-width: 1280px;"></video>
<script>
const video = document.getElementById('video');
const src = 'https://videos.jeffemmett.com/live/my-stream/my-stream.m3u8';
if (Hls.isSupported()) {
const hls = new Hls();
hls.loadSource(src);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.play();
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = src;
}
</script>
</body>
</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`

View File

@ -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

View File

@ -0,0 +1 @@
40373

View File

@ -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;
}
}
}

View File

@ -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

View File

@ -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

66
streaming/start-streaming.sh Executable file
View File

@ -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 ""

37
streaming/stop-streaming.sh Executable file
View File

@ -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 ""

1188
worker/admin.html Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1785
worker/video-server-final.js Normal file

File diff suppressed because it is too large Load Diff

1941
worker/video-server.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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 }
# ]

34
worker/wrangler.toml Normal file
View File

@ -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