Run an unattended, long-duration (multi-week) 24/7 livestream - a wildlife nest camera is the reference use case, from a single fixed USB webcam to YouTube Live, even on modest or older Linux hardware. Built on ffmpeg + systemd, not OBS (the correct tool for human-in-the-loop interactive streaming): no GUI needed, nor desirable, for a reliable static single-source feed. Reliability instead comes from a belt-and-suspenders stack of independent control loops watching over the stream - see Architecture below.
The reference deployment is a wood pigeon (Columba palumbus) nest camera on a residential balcony. Every default is overridable via config.yaml, so the system works for any other typical hardware and software - and for non-pigeon subjects too !
Independent control loops around the core ffmpeg process, each catching a failure the others structurally cannot see:
- systemd
Restart=always— recovers from ffmpeg exiting. - The watchdog (
pigeoncam-watchdog.sh) — recovers from ffmpeg hanging while still running, whichRestart=alwayscannot detect. A stall that survives one plain restart escalates to a USB-level device reset (pigeoncam-usb-reset.sh) before retrying. - The rotation timer (
pigeoncam-rotate.sh) — a scheduled restart to stay under YouTube's ~12h continuous-archive ceiling. A policy action, not a failure recovery, kept deliberately separate from the watchdog. - The external status check (
pigeoncam-status-check.sh) — verifies YouTube itself is actually broadcasting, which nothing above can see: a broadcast stuck at "Preparing stream" looks perfectly healthy locally. Every poll is classified confirmed-live, confirmed-not-live, or indeterminate, and only confirmed-not-live may trigger a restart, so a network blip cannot start a restart storm.
Two optional checks go a layer deeper, both analysing a single frame fetched periodically from the live stream. Off by default — they fetch real video on a schedule, so opt in deliberately:
external_check.frame_freeze— catches YouTube's relay serving a stuck picture while still reporting itself live.external_check.frame_border— catches YouTube rendering the picture with black borders down the sides or top and bottom, which it occasionally starts doing after a reconnect (symptom and fix). Can force a rotation to clear it.
Both only sample in daylight: a near-dark frame carries too little real sensor variation to tell "stuck" from "dark", and too little brightness to tell a black border from a dim one.
Full diagram and reasoning: SPEC.md §4.
sudo apt update
sudo apt install -y ffmpeg v4l-utils usbutils procps jq uhubctl yq shellcheckyq here is the kislyuk/yq wrapper around
jq (same package name on Debian/Ubuntu) — every script reads
config.yaml through it.
yt-dlp is deliberately not installed via apt or pip (it tracks
YouTube's frontend closely; a distro-packaged or system-pip version can
lag and silently misparse the page, and nothing here would ever re-run
pip install -U on it). Install the standalone release binary instead,
which supports safe in-place self-update:
sudo curl -fL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp
sudo chmod a+rx /usr/local/bin/yt-dlpStep 5 below installs pigeoncam-ytdlp-update.timer, which runs yt-dlp -U
as root once a day so the binary stays current for the life of the
deployment without manual intervention — root because it already owns
/usr/local/bin, the same reason every other unit in this project runs as
root rather than a dedicated service account.
Clone or copy this repository to /opt/PigeonCamSteward, the default
install path. To use a different one, pass it to make install in step 5
(sudo make install PREFIX=/usr/local/lib/pigeoncam) and it will be
written into the systemd units for you.
Ownership: own the checkout as yourself, not root - git pull and any
script tinkering then don't need sudo each time, and it costs nothing
security-wise since the systemd units below run as root regardless of who
owns the files they exec (root can always read/execute them; that's
independent of ownership). What should stay root:root is /etc/pigeoncam
(step 3) - the stream key and any YouTube Data API credentials - since a
600-mode file you own is readable by anything running as you, while a
root-owned one needs an actual privilege-escalation step even from a
compromised process running as you.
sudo mkdir -p /opt/PigeonCamSteward
sudo chown "$USER":"$USER" /opt/PigeonCamSteward
git clone https://github.com/liotier/PigeonCamSteward.git /opt/PigeonCamSteward
sudo cp /opt/PigeonCamSteward/udev/99-pigeoncam.rules.example /etc/udev/rules.d/99-pigeoncam.rules
# edit it with your camera's idVendor/idProduct (see the comments in the file), then:
sudo udevadm control --reload
sudo udevadm trigger
ls -l /dev/pigeoncam # should now existTo pick up later changes: cd /opt/PigeonCamSteward && git pull.
The easy way - an interactive wizard that asks the handful of questions that actually need a human answer (camera device, stream key, channel URL, ...) and leaves the rest of config.yaml at its documented default, comments and all:
sudo /opt/PigeonCamSteward/bin/pigeoncam-setup.shRe-run it any time to review or change an answer - every prompt shows the current value and Enter keeps it. It never enables or starts anything.
The manual way, if you'd rather edit the file yourself:
sudo mkdir -p /etc/pigeoncam
sudo cp /opt/PigeonCamSteward/config.example.yaml /etc/pigeoncam/config.yaml
sudo $EDITOR /etc/pigeoncam/config.yaml # at minimum: youtube.ingest_url, external_check.channel_live_url, archive.segment_dir
# your YouTube stream key - a disposable, Studio-revocable credential, but
# keep it out of git and off multi-user hosts casually anyway:
sudo mkdir -p /etc/pigeoncam
echo 'your-stream-key-here' | sudo tee /etc/pigeoncam/stream_key >/dev/null
sudo chmod 600 /etc/pigeoncam/stream_keyFull schema and every default: config.example.yaml, which documents every key inline.
archive.segment_dir has no default and must be set before local
recording will run — the doctor fails while it is empty, and the stream
service refuses to start. That is deliberate. Recordings are your data,
they are tens of GB per day, and the right filesystem is whichever one on
your machine has the room — something no default can know. Point it at a
data disk or a mount of your own (/srv/pigeoncam/archive, an external
drive); avoid /var/lib, which is where programs keep their own state and
where a package manager may delete things on uninstall. Set
archive.enabled: false if you don't want local recording at all.
Set notify_command if you want to hear about problems. It is empty by
default, which means every alert this system raises — a health check going
blind, a failing rotation, a unit that won't start — reaches only the
system journal. It takes a shell command with the event label as $1 and
the message as $2, so anything that can send you a message works:
notify_command: '/usr/local/bin/ntfy-send "$1" "$2"'Worth testing that it actually reaches you before you need it — the alerting path is not exercised by the alerts existing.
Storage sizing: there is deliberately no automatic storage budget —
drive sizes vary too much to hardcode. bin/pigeoncam-doctor.sh (next
step) prints the formula and a current estimate for your config; as a
rough anchor, a 6 Mbit/s stream keeping 16.5 daytime hours a day runs to
40+ GB/day, and the default retention keeps far less than that.
sudo PIGEONCAM_CONFIG=/etc/pigeoncam/config.yaml /opt/PigeonCamSteward/bin/pigeoncam-doctor.shFix everything it flags before proceeding — it exists specifically to catch the failure modes in § Read this before you build anything before they cost you a debugging session. The systemd start-limit check will WARN (not FAIL) until step 5 installs the unit file.
cd /opt/PigeonCamSteward && sudo make install
sudo systemd-tmpfiles --create /etc/tmpfiles.d/pigeoncam.conf
sudo systemctl daemon-reload
sudo /opt/PigeonCamSteward/bin/pigeoncam-ctl.sh enable
sudo /opt/PigeonCamSteward/bin/pigeoncam-ctl.sh startmake install copies the tree into place and installs the systemd units,
rewriting the install path into them if you chose a different PREFIX=.
It never starts or enables anything, and never overwrites an existing
/etc/pigeoncam/config.yaml. sudo make uninstall reverses it, leaving
your config and recordings alone.
Watch it come up:
journalctl -u pigeoncam-stream -fThen confirm on https://www.youtube.com/@<your-handle>/live.
Note: two of the timer files' OnUnitActiveSec= values mirror
config.yaml's defaults (watchdog.check_interval_seconds,
external_check.poll_interval_seconds) but are not read from
config.yaml — if you change one of those, update the matching
systemd/*.timer file too and re-run daemon-reload.
pigeoncam-doctor.sh (step 6 below) warns if the two drift apart.
Rotation is different: pigeoncam-rotate.timer just checks every 5
minutes whether a rotation is actually due, so changing
youtube.rotation.interval alone is enough — no timer file edit needed.
bin/pigeoncam-ctl.sh handles day-to-day
start/stop/enable/disable/restart/status against all six units at once —
see § Operations.
Now that the unit is installed, pigeoncam-doctor.sh can check the
start-limit setting for real:
sudo PIGEONCAM_CONFIG=/etc/pigeoncam/config.yaml /opt/PigeonCamSteward/bin/pigeoncam-doctor.sh --unit-file /etc/systemd/system/pigeoncam-stream.serviceEverything above is a complete, working deployment on its own. If you want overlap-free scheduled rotation or the stuck-broadcast recovery path, see § YouTube Data API rotation and recovery below and docs/YOUTUBE-API.md.
Once the units are installed (Quickstart step 5), bin/pigeoncam-ctl.sh
applies a single verb to all six at once — pigeoncam-stream.service plus
the watchdog/status-check/rotate/archive-trim/ytdlp-update timers —
instead of naming each one by hand:
sudo /opt/PigeonCamSteward/bin/pigeoncam-ctl.sh start # start everything
sudo /opt/PigeonCamSteward/bin/pigeoncam-ctl.sh stop # stop everything
sudo /opt/PigeonCamSteward/bin/pigeoncam-ctl.sh restart # e.g. after `git pull`
sudo /opt/PigeonCamSteward/bin/pigeoncam-ctl.sh enable # survive a reboot
sudo /opt/PigeonCamSteward/bin/pigeoncam-ctl.sh disable # opt out of reboot survival
sudo /opt/PigeonCamSteward/bin/pigeoncam-ctl.sh status # one-glance active/enabled tablestart/restart/enable apply in that order (stream service first);
stop/disable apply in reverse (stream service last), so the long-running
service is always the last thing torn down and the first thing brought up.
status exits non-zero if any unit isn't both active and enabled — handy
for scripting a quick health check. One unit failing doesn't stop the rest
from being attempted; pigeoncam-ctl.sh reports everything that failed at
the end rather than stopping at the first one.
This only calls systemctl against units already installed — it never
copies unit files, runs daemon-reload, or touches config.yaml. For a
finer-grained diagnosis of why a unit isn't behaving, bin/pigeoncam-doctor.sh
remains the tool for that.
api/rotate_via_api.py implements
SPEC.md §5.4.1's
YouTube Data API call sequence: explicitly transition the outgoing
broadcast to complete, insert + bind a new one to your persistent
stream, wait for streamStatus=active, then transition the new broadcast
to live. Off by default (youtube_api.enabled: false) since it needs a
one-time Google Cloud Console + OAuth setup the restart-based default
doesn't — full setup walkthrough: docs/YOUTUBE-API.md.
Two independent things it unlocks, gated separately (youtube.rotation.mode
picks how routine rotation happens; youtube_api.enabled gates whether this is
available at all, including for recovery — see the table in
docs/YOUTUBE-API.md):
youtube.rotation.mode: api— overlap-free scheduled rotation with custom title/description/category per broadcast, replacing the restart-based default. Requiresyoutube_api.enabled: true; settingapimode without it fails loudly at rotation time rather than silently falling back torestart.- Last-resort stuck-broadcast recovery — once
pigeoncam-status-check.shhitsmax_restarts_before_escalationconsecutive not-live restarts, it attempts this recovery sequence ifyoutube_api.enabled: true(loggingYOUTUBE_API_ESCALATION), or logs a clear "manual Studio intervention may be required" message and backs off its restart cadence if not (ESCALATION_UNAVAILABLE) — this works independently ofyoutube.rotation.mode.
Strongly recommended for unattended deployments running more than a day
or two unsupervised, specifically for the recovery path: field testing
reproduced a broadcast stuck at "Preparing stream" that survived repeated
plain restarts and only resolved by abandoning the broadcast context
entirely — the kind of stuck state only this explicit transition/bind
sequence can force past. See
docs/TROUBLESHOOTING.md
for the manual recipe if you'd rather not set this up.
By default, rotation happens on a plain repeating clock (youtube.rotation.interval,
11h45m) and the archive's daytime window is a fixed pair of clock times
(archive.daytime_start/daytime_end, 04:00–20:30). Both work fine
year-round and need no setup.
If you'd rather the day be centred on the sun instead of the clock, set your coordinates once and switch two settings independently:
location:
latitude: 48.8566 # your camera's actual location
longitude: 2.3522
youtube:
rotation:
schedule: solar # instead of interval
archive:
daytime_mode: solar # instead of fixedyoutube.rotation.schedule: solar— one broadcast centred on solar noon, as long asyoutube.rotation.intervalallows, plus two shorter broadcasts splitting the night at solar midnight. In summer this broadcast cannot cover the whole of daylight — YouTube's own ~12-hour archive limit is shorter than a June day at most latitudes — so it clips roughly the same amount of early morning and late evening rather than favouring either end. In winter it comfortably covers the whole (shorter) day with room to spare.archive.daytime_mode: solar— the retention window (and the optional frozen-frame checks, which reuse the same window) tracks actual sunrise/sunset-ish hours instead of a fixed clock range, so it shrinks in winter and grows in summer along with real daylight.
Both compute sunrise, sunset, and solar noon locally from your
coordinates and the system clock — no external almanac service, no
network call. If location.latitude/longitude are left unset (or
unparseable) while one of these is switched to solar, that feature
falls back to its plain fixed-schedule behaviour and logs a warning
rather than breaking rotation or archiving — bin/pigeoncam-doctor.sh
flags this setup mistake explicitly, and prints today's actual computed
rotation times once it's fixed. Full design rationale (including why the
archive window isn't simply reused for rotation, and vice versa):
docs/development/design/solar-scheduling.md.
There is no published .deb, but a debian/ directory is included -
build your own from this source tree:
sudo apt install debhelper dpkg-dev
dpkg-buildpackage -us -uc -b
sudo dpkg -i ../pigeoncam_*.debThe package starts and enables nothing either - it points at
pigeoncam-setup.sh then pigeoncam-doctor.sh and stops there. See
debian/README.Debian for what differs from the
quickstart above (config paths, and a yt-dlp self-update timer that
ships disabled on purpose - see there for why) and
docs/development/design/debian-packaging.md
for the reasoning behind every packaging decision.
Five traps that cost a debugging session each if you meet them the hard
way. bin/pigeoncam-doctor.sh checks for most of them automatically.
Full detail and diagnostic commands: docs/TROUBLESHOOTING.md.
- Use MJPEG, not YUYV, at 1080p30+ over USB 2.0. Uncompressed YUYV at
1080p is bandwidth-capped by the UVC driver to ~5 fps on USB 2.0. This
fails silently — capture "works," just at an unannounced low frame
rate. Run
bin/pigeoncam-doctor.shbefore your first stream; it checks this. - A silent or absent audio track can leave YouTube stuck at "Preparing
stream" indefinitely, with no error from ffmpeg. This is not a
connection problem. Default
audio.mode: synthetic(a very-low-amplitude noise floor) avoids it;audio.mode: offis deliberately supported but not recommended for exactly this reason. - Use RTMPS, not RTMP, for the YouTube ingest URL — a different URL from the one Studio shows by default (click the lock icon to reveal it).
- USB topology matters more than USB spec. Bus-powered hub chains and marginal power budgets are a common source of unexplained overnight disconnects. See docs/HARDWARE.md.
- Share
https://www.youtube.com/@<handle>/live, never a specific video URL. Broadcast rotation (below) does not guarantee a stable video ID; the/liveredirect always resolves to whatever is currently live, so rotation is a non-issue for viewers regardless of which rotation method is in use.
The Unlicense — public domain, use it however you like.
ffmpeg, v4l-utils, uhubctl, yt-dlp, and jq/yq are invoked as
separate subprocesses; the optional YouTube API helper imports Google's
Apache-2.0 client libraries. Neither pattern imposes a copyleft
requirement on your use of this project. (SPEC.md's header names GPLv3
from an early planning pass and is superseded by the LICENSE file.)
If you are running a camera:
- docs/HARDWARE.md — camera/USB topology/autofocus/ outdoor deployment guidance.
- docs/TROUBLESHOOTING.md — expanded write-ups of every pitfall above, with diagnostic commands and log signatures.
- docs/YOUTUBE-API.md — optional: connect your YouTube account so the system can rotate broadcasts cleanly and recover from a stuck broadcast on its own.
- tools/pigeoncam-offline-reencode.sh
— standalone batch re-encode for the archive directory, meant to run on
a separate, stronger-CPU host than the pigeon-cam itself (
--helpfor usage); unlikereencode.enabled(off by default, throttled to run low-priority alongside the live stream on the same host), this needs nothing but ffmpeg/ffprobe and no project checkout at all.
If you are changing the code:
- docs/development/ORIENTATION.md — start here: how the pieces fit together, the conventions that will surprise you, and how the test harness works.
- docs/development/ — the rest of the development documentation: design specs, incident post-mortems, working agreements, and the glossary translating the specification's internal vocabulary into the plain language the user-facing docs use.
- SPEC.md — the frozen requirements this implementation is measured against. Not edited.
- tests/MANUAL_VERIFICATION.md — the state of manual testing against real hardware and a real YouTube channel, and which checks still need it.
