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:
commit
76b9485d2c
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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`.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"CORSRules": [
|
||||||
|
{
|
||||||
|
"AllowedOrigins": ["*"],
|
||||||
|
"AllowedMethods": ["GET", "HEAD"],
|
||||||
|
"AllowedHeaders": ["*"],
|
||||||
|
"MaxAgeSeconds": 3600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""OBS to R2 Uploader - Upload OBS recordings to Cloudflare R2 storage."""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
|
@ -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}"
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 ""
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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 ""
|
||||||
|
|
@ -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 ""
|
||||||
|
|
@ -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 ""
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 "=================================================="
|
||||||
|
|
@ -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 %*
|
||||||
|
|
@ -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 "$@"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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`
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
40373
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 ""
|
||||||
|
|
@ -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 ""
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||||
|
|
@ -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 }
|
||||||
|
# ]
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue