Skip to content

About

Permanent live video stream watchdog on Linux - ensures chain reliability from ffmpeg to Youtube Live

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

125 Commits

Folders and files

Repository files navigation

PigeonCamSteward

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 !

PigeonCamSteward live banner

Architecture

Independent control loops around the core ffmpeg process, each catching a failure the others structurally cannot see:

  1. systemd Restart=always — recovers from ffmpeg exiting.
  2. The watchdog (pigeoncam-watchdog.sh) — recovers from ffmpeg hanging while still running, which Restart=always cannot detect. A stall that survives one plain restart escalates to a USB-level device reset (pigeoncam-usb-reset.sh) before retrying.
  3. 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.
  4. 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.

Quickstart

1. Install dependencies

sudo apt update
sudo apt install -y ffmpeg v4l-utils usbutils procps jq uhubctl yq shellcheck

yq 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-dlp

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

2. Place the project and the udev rule

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 exist

To pick up later changes: cd /opt/PigeonCamSteward && git pull.

3. Configure

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

Re-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_key

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

4. Run the doctor script

sudo PIGEONCAM_CONFIG=/etc/pigeoncam/config.yaml /opt/PigeonCamSteward/bin/pigeoncam-doctor.sh

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

5. Install and start the systemd units

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 start

make 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 -f

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

6. Re-run the doctor script

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

7. YouTube Data API rotation and recovery

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

Operations

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 table

start/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.

YouTube Data API rotation and recovery

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. Requires youtube_api.enabled: true; setting api mode without it fails loudly at rotation time rather than silently falling back to restart.
  • Last-resort stuck-broadcast recovery — once pigeoncam-status-check.sh hits max_restarts_before_escalation consecutive not-live restarts, it attempts this recovery sequence if youtube_api.enabled: true (logging YOUTUBE_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 of youtube.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.

Solar-relative scheduling (optional)

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 fixed
  • youtube.rotation.schedule: solar — one broadcast centred on solar noon, as long as youtube.rotation.interval allows, 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.

Installing from a package

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_*.deb

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

Known gotchas

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.sh before 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: off is 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 /live redirect always resolves to whatever is currently live, so rotation is a non-issue for viewers regardless of which rotation method is in use.

License

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

Further reading

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 (--help for usage); unlike reencode.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.

About

Permanent live video stream watchdog on Linux - ensures chain reliability from ffmpeg to Youtube Live

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Used by

Contributors

Languages