A FastAPI-based web application for scheduling and managing live camera streams for ball field events. FieldCam allows users to schedule streaming sessions, automatically capturing and serving field camera images for scheduled games and practices.
- Live Field Camera Integration - Capture and serve real-time field images
- Job Scheduling - Schedule streaming sessions with start/end times
- User Authentication - Secure login system with session management
- Automated Streaming - Automatically start/stop streams based on schedule
- Docker Support - Single published container, runs anywhere Docker does
- Job Management - Web interface for adding, listing, and removing scheduled jobs
The camapp container image is automatically built and pushed to GitHub Container Registry (GHCR) on every push to main that changes files under cam-app/. The published image is the recommended way to run FieldCam.
- Docker (24+)
- A directory on the host for runtime state (
jobs/,logs/) - An RTSP-capable IP camera (only required for the camera capture cron, not for the app to start)
docker pull ghcr.io/jkrauska/fieldcam/camapp:latestThe image is currently built for linux/arm64 (Raspberry Pi / Apple Silicon). If you need linux/amd64, update platforms in .github/workflows/build-camapp.yml and rebuild.
Create a working directory and a .env file inside it (see cam-app/.env.example in the repo for a template you can copy if you cloned it):
mkdir -p ~/fieldcam && cd ~/fieldcam
mkdir -p jobs logs
touch .envMinimum required variables:
| Variable | Description |
|---|---|
SECRET_KEY |
Random string for session signing — generate with openssl rand -hex 32 |
PASSWORDS |
Comma-separated list of allowed login passwords |
ADMIN_PASSWORD |
Password that unlocks the settings page |
CAMERA_IP |
IP address of the RTSP camera |
CAMERA_USER |
Camera username |
CAMERA_PASS |
Camera password |
Optional variables:
| Variable | Default | Description |
|---|---|---|
TIMEZONE |
America/Los_Angeles |
Timezone for scheduling |
COOKIE_NAME |
stream411_login |
Login cookie name |
TOKEN_EXPIRY_MINUTES |
30 |
Login session duration in minutes |
LOCATION |
Tepper |
Location name shown in UI |
BLACKOUT_SEASON |
— | Shown in schedule form warning |
BLACKOUT_TEAMS |
— | Shown in schedule form warning |
RTMP_GAMECHANGER |
(preset) | RTMP base URL for GameChanger |
RTMP_YOUTUBE |
rtmp://a.rtmp.youtube.com/live2 |
RTMP base URL for YouTube |
JOBS_DB_PATH |
sqlite:///jobs/jobs.sqlite |
SQLite database path (inside the container) |
FIELD_IMAGE_PATH |
/tmp/field.jpg |
Path the app reads the field snapshot from |
Example .env:
SECRET_KEY=replace-with-openssl-rand-hex-32
PASSWORDS=changeme1,changeme2
ADMIN_PASSWORD=changeme-admin
CAMERA_IP=192.168.1.50
CAMERA_USER=admin
CAMERA_PASS=supersecret
TIMEZONE=America/Los_Angeles
LOCATION=Tepper
RTMP_YOUTUBE=rtmp://a.rtmp.youtube.com/live2docker run -d \
--name camapp \
--restart unless-stopped \
-p 9090:9090 \
-v "$(pwd)/.env:/code/.env" \
-v "$(pwd)/jobs:/code/jobs" \
-v "$(pwd)/logs:/code/logs" \
-v /tmp/field.jpg:/tmp/field.jpg:ro \
ghcr.io/jkrauska/fieldcam/camapp:latestThe app is now available at http://localhost:9090. Health check: GET /health.
Notes on the volumes:
.env— bind-mounted so the in-app Settings page (admin only) can persist edits back to the host file. On save it rewrites.envand SIGTERMs the process; the--restart unless-stoppedpolicy then brings the container back with the new values. Without this mount, settings edits are silently discarded on restart. (You can substitute--env-file .envif you don't need the in-app editor — values will still load at boot — but writes from the Settings page won't survive.)jobs/— SQLite job/state database (must persist across restarts).logs/— FFmpeg / app log files./tmp/field.jpg— read-only bind so the container can serve the snapshot the host writes (see step 4). Pre-create the file withtouch /tmp/field.jpgbefore starting the container, otherwise Docker will create a directory at that path instead. Skip this mount entirely if you don't yet have a cron capture set up — the app starts fine without it.
Optional mounts:
-v /sys/class/thermal:/sys/class/thermal:ro— exposes Pi CPU temperature to the data page.
The app serves the file at FIELD_IMAGE_PATH (default /tmp/field.jpg) at /dynamic/field.jpg. The simplest way to populate it is a host-side cron job using ffmpeg:
crontab -e* * * * * /usr/bin/ffmpeg -hide_banner -loglevel error -y \
-i rtsp://USERNAME:PASSWORD@IPADDRESS:554/Streaming/channels/102/ \
-frames:v 1 -q:v 2 /tmp/field.jpgUsing /tmp keeps the file world-readable so any user (and the bind-mounted container) can read it.
docker logs -f camapp # tail logs
docker restart camapp # restart in place
docker pull ghcr.io/jkrauska/fieldcam/camapp:latest \
&& docker rm -f camapp \
&& docker run -d ... (same flags as above) # update to latest imageGET /login— Login formPOST /login— Handle login submissionGET /health— Health check (for Docker / orchestrator probes)
GET /— Main SPA shell (list of scheduled jobs)GET /dynamic/field.jpg— Serve current field image (no cache)GET /add— Add new streaming job formPOST /submit— Submit new streaming jobPOST /remove_job— Remove a scheduled jobPOST /cancel_stream— Cancel an active streamGET /data— Data / metrics pageGET /logout— Log out current userGET /version— Application version information
- Navigate to
http://localhost:9090/ - Log in with one of the passwords from
PASSWORDS(orADMIN_PASSWORDfor admin features) - Click Add Job
- Fill in the form:
- Team Name — Name of the team or event
- Date — Date of the game (YYYY-MM-DD)
- Start Time — Stream start time (HH:MM)
- End Time — Stream end time (HH:MM)
- Stream Key — (Optional) Custom stream key or auto-generated
- Click Submit
- View all scheduled jobs on the main page.
- Remove jobs with the remove button next to each job.
- Jobs are automatically executed based on their scheduled time.
Access the live field image at /dynamic/field.jpg (requires authentication).
Install uv — a fast Python package installer:
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/envcd cam-app
uv sync --all-extras
pre-commit installcd cam-app
uv run uvicorn app.main:app --reload --port 9090The app loads its config from cam-app/.env if present (resolved relative to the cam-app/ project root, not the current working directory).
This project uses Ruff for linting and formatting. Always run the following from cam-app/ before committing:
uv run ruff check . && uv run ruff format .Pre-commit hooks (pre-commit install) run these automatically on commit. GitHub Actions also runs ruff checks on every PR — see .github/workflows/lint.yml.
The published image is the canonical build, but you can build locally:
cd cam-app
docker build -t camapp:latest .For an iterative rebuild loop (Linux only, requires inotify-tools):
cd cam-app
./build.sh nowThis watches app/ for changes and rebuilds the image after a 60s debounce. Restart the running container manually after each rebuild (docker rm -f camapp && docker run ...).
Detection runs on ONNX Runtime against pre-exported models committed under cam-app/app/models/ — no torch or ultralytics in the image, no model download at runtime. This keeps the image small and fast on a Raspberry Pi (ONNX is ~2× faster than PyTorch there). The app still degrades gracefully: if onnxruntime or the model file is missing, detection routes return an error and the rest of the UI keeps working.
Two models ship in the image; select via the YOLO_MODEL setting (env var or settings page):
| Model | YOLO_MODEL value |
Notes |
|---|---|---|
| Nano (default) | app/models/yolov8n.onnx |
~12 MB, fastest (~130 ms/img on Pi 5) |
| Medium | app/models/yolov8m.onnx |
~50 MB, more accurate at distance, ~3–4× slower |
YOLO_MODEL must point at an .onnx file — a stale .pt value (from the old torch path) will return a clear error.
To regenerate or add a model (e.g. yolov8s), use the export extra — it pulls in ultralytics + torch locally only:
cd cam-app
uv run --extra export python -c "from ultralytics import YOLO; YOLO('yolov8n.pt').export(format='onnx', imgsz=640, opset=12)"
mv yolov8n.onnx app/models/yolov8n.onnx- FastAPI — Modern Python web framework
- Uvicorn — ASGI server
- APScheduler — Job scheduling
- SQLAlchemy — Database ORM
- Jinja2 — Template engine
- FastAPI-Login — Authentication
- Datastar — Lightweight hypermedia frontend (CDN script, no npm); backend-driven UI with HTML patch responses
- Docker — Containerization
The list and add pages use Datastar so that actions (remove job, cancel stream, submit new stream) update the page via HTML morphing instead of full reloads:
- List page (
/): Remove and Cancel buttons submit via@post(..., {contentType: 'form'}). The server returns an HTML fragment for#list-content, which Datastar morphs into the DOM. - Add page (
/add): The form usesdata-on:submit="@post('/submit', {contentType: 'form'})". On success the server returns a fragment for#add-form-container(success message + link back to list).
No frontend build step or Datastar SDK is required; the client loads the Datastar script from the CDN, and the backend returns plain HTML fragments with the expected element IDs.
docker logs -f camapp # follow logs
docker restart camapp # restart
docker exec -it camapp /bin/sh # shell into the containerCheck the host cron job is running:
crontab -lVerify FFmpeg can talk to the camera:
ffmpeg -i rtsp://USERNAME:PASSWORD@IPADDRESS:554/Streaming/channels/102/ -frames:v 1 test.jpgVerify your .env file is being read by the container:
docker exec camapp env | grep -E "SECRET_KEY|CAMERA_IP|PASSWORDS"If those are empty, the --env-file path is wrong or the variables are missing from the file.
If the container exits immediately with a "Missing required configuration" message, one of the required variables (SECRET_KEY, etc.) is missing or empty. Re-check the .env file against the table above.
Contributions are welcome — please open a pull request.
See LICENSE file for details.
For issues, questions, or contributions, please visit the GitHub repository.