commit 2015c4911f2106d0084a62cdd155f7fff44f404d Author: jpmvaz Date: Sun Sep 13 20:24:20 2026 +0100 v_2 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7273b69 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +data/ +cameras.json +__pycache__/ +*.pyc +README.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3b13374 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Camera Station — a self-hosted viewer and recorder for MJPEG and RTSP cameras. +# +# docker compose up -d +# +# Everything the service produces (recordings, photos, cameras.json) is written +# to /data, which docker-compose.yml maps to ./data on the host so it survives +# the container being rebuilt. + +FROM python:3.12-slim + +# ffmpeg is the only real dependency, and only RTSP cameras need it. +# tini gives the container a real init process, so stopping it delivers a clean +# signal and ffmpeg closes the segment it is writing instead of losing it. +# tzdata matters more than it looks: filenames are stamped with local time, and +# without it the TZ setting is ignored and everything is named in UTC. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg tini tzdata \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY server.py index.html config.html library.html ./ + +ENV CAMERA_STATION_DATA=/data \ + PYTHONUNBUFFERED=1 +VOLUME ["/data"] +EXPOSE 8000 + +# Report unhealthy if the camera list stops answering. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/cameras', timeout=4).status==200 else 1)" + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["python3", "server.py", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..746f342 --- /dev/null +++ b/README.md @@ -0,0 +1,410 @@ +# Camera Station + +A self-hosted page for watching network cameras, recording them to a file, and +taking photos from the live picture. It handles both MJPEG feeds (MJPG-Streamer and +similar) and RTSP cameras. + +Two cameras are configured out of the box: + +| | Camera | Type | Address | +| --- | --- | --- | --- | +| 1 | Camera 1 | MJPEG | `192.168.69.79:8080` | +| 2 | Camera 2 | RTSP | `192.168.69.128:554` | + +## Run it with Docker + + docker compose up -d + +Then open **http://localhost:8000**. That is the whole install: the image brings its +own ffmpeg, and the container restarts by itself after a crash or a reboot, so it +behaves as a service rather than something you have to remember to start. + +Everything it produces lands in **./data** beside the compose file: + + data/cameras.json your cameras + data/recordings// footage, in timestamped pieces + data/photos/ photos, named date_time_camera + +That folder is the thing worth backing up; the image can always be rebuilt. + +Useful commands: + + docker compose logs -f # watch it + docker compose up -d --build # rebuild after editing any of the files + docker compose restart # after changing settings + docker compose down # stop; recordings are kept + +The image is built from the `Dockerfile` here — there is nothing to download from a +registry, so all six files need to be in the same folder before you start: + + Dockerfile docker-compose.yml .dockerignore + server.py index.html config.html library.html + +If you see **`pull access denied for camera-station`**, Compose is trying to fetch the +image instead of building it. Run `docker compose up -d --build` once. (Earlier +versions of this compose file named the image, which invited exactly that; the current +one does not.) + +Set `TZ` in `docker-compose.yml` to your own zone before you start, because photos and +recordings are named with local time. + +Stopping is given 20 seconds so ffmpeg can close the file it is writing. + +## Run it without Docker + +Keep the three files in the same folder, then: + +```bash +python3 server.py +``` + +Open **http://localhost:8000**. Python 3.7+ and no pip install. + +**ffmpeg is required for the RTSP camera** and only for that; the MJPEG camera needs +nothing. If ffmpeg is missing, camera 2 appears greyed out with the reason on screen +and camera 1 carries on working. Install it with `apt install ffmpeg`, +`brew install ffmpeg`, or from ffmpeg.org. + +Configure cameras yourself with `--camera "Name=url"`, repeated as many times as you +like. Anything starting `rtsp://` is decoded automatically: + +```bash +python3 server.py \ + --camera "Workshop=http://192.168.69.79:8080" \ + --camera "Driveway=rtsp://admin:secret@192.168.69.128:554/cam/realmonitor?channel=1&subtype=0" + +python3 server.py --host 127.0.0.1 # only this machine can connect +``` + +Names you give here are what appear on the tabs and in saved filenames. + +## Using it + +| Action | Control | Key | +| --- | --- | --- | +| Switch camera | Camera tabs | 1 2 | +| Start / stop recording on the server | Record button | R | +| Take a photo, saved on the server | Take photo button | Space | +| Zoom in / out | Scroll, pinch, or the +/− buttons | + | +| Pan while zoomed | Drag the picture | arrow keys | +| Back to the whole frame | Fit button, or double-click | 0 | +| Switch camera | Tabs above the picture | 1 2 | + +Each camera keeps its own zoom, pan and frame-rate history, so switching back and +forth doesn't disturb how you had either one framed. + +**A camera keeps recording while you watch another one.** Its tab shows a red marker +so you can see it is still running. The tally strip and the readouts always describe +the camera you are currently looking at. + +The page only holds open the feeds it actually needs — the one on screen, plus any +that are recording. Switching away from an RTSP camera lets the server shut down its +ffmpeg process instead of decoding video nobody is watching. +The header carries a live clock. Below the picture, the frame rate cell shows the +current rate plus a meter of the last 60 seconds — one bar per second, auto-scaled, +with dim bars for seconds that fell well short of the usual rate and red bars for +seconds where nothing arrived at all. It makes a camera that stutters under load +obvious at a glance, which a single number does not. + +**Stamp time** draws the date and time into the bottom corner of the picture itself, +so it is baked into photos and recordings rather than just shown on the page. It's off +by default. The stamp is drawn after each frame is checked for changes, so a ticking +clock never gets mistaken for a live picture — freeze the camera with the stamp on and +the meter still correctly drops to zero. + +Photos download immediately and also stay in the roll at the bottom of the page so +you can save one again. The PNG/JPEG toggle sets the format. + +## Recording + +Each camera has a **Recording / Not recording** button on the set-up page, and the +Record button in the viewer does the same thing. When a camera is recording: + +* It is recorded **continuously, on the server**, whether or not any browser is open. + Closing the page changes nothing. +* The stream is **copied, not re-encoded**. What lands on disk is exactly the + bitstream the camera sent — no quality lost, and very little CPU used. Decoding is + only needed to *watch* a camera, not to record it. +* Footage is cut into 10-minute pieces named `YYYYMMDD-HHMMSS.mkv`, so old material + can be removed a piece at a time. + +Matroska (`.mkv`) is used rather than MP4 on purpose: a file that is still being +written stays playable if the machine loses power, where an unfinalised MP4 would be +lost. VLC, ffmpeg and most players open them; Windows Media Player does not. + +If a camera drops off the network its recorder stops with it, and is started again +automatically once the camera answers. + +### The size limit + +**10 GB of footage is kept.** Once the total passes that, the oldest pieces are +deleted until it is back under, so storage never grows without bound. The piece +currently being written is never deleted, which is why usage can sit a little above +the limit for a few minutes. + +The limit is the total across all cameras, not per camera. Change it in +`docker-compose.yml`, or with `--storage-limit 25` for 25 GB. Photos are not counted +against it — they are tiny by comparison — but the set-up page shows what they use. + +The set-up page shows how full the store is; the **Files** page lists everything and +lets you download or delete individual items. + +### How long it has been running + +The recording timecode and the uptime figure both count in days, hours, minutes and +seconds — `3d 04:05:06`. The day marker only appears once there is a day to show, so +a short recording still reads plainly as `00:04:12`. + +This matters more than it sounds for a service that is meant to be left alone: +counting only minutes and seconds would quietly wrap back to zero every hour, and a +camera that had been recording for a week would look like it had just started. + +## Photos + +**Take photo** saves straight to the server. Nothing is downloaded and nothing asks +where to put it. Files are named: + + 20260721_143052_Driveway.png date_time_cameraname + +Two photos inside the same second get `-2`, `-3` and so on rather than overwriting +each other. + +What is saved is exactly what you are framing: if you have zoomed in, the photo is +the cropped region at its own pixel count, cut from the original frame with no +resampling. The roll under the picture lists what has been saved and links to each +file if you also want a copy on the machine you are sitting at. + +## Browsing what has been saved + +**http://localhost:8000/files**, or *Files* from the viewer. + +Two tabs. **Recordings** lists every stored segment newest first, grouped by day, +with the camera it came from and its size; **Photos** shows a thumbnail grid. Both +can be filtered by camera and by date, and every entry has a Download link. + +The segment a camera is writing into right now is marked **recording now**. You can +still download it — you will get it as far as it had got — and its Download button +says *Download part* so that is not a surprise. It is also the one file the delete +button refuses to remove, since deleting it would not stop the recording, only lose +the footage being captured. + +Selecting files and pressing **Delete selected** removes them for good, after a +confirmation. This is the same store the 10 GB limit applies to, so deleting by hand +simply buys time before the automatic clean-up needs to. + +Photos open in a tab when clicked. Recordings do not: they hold the camera's own +stream in a Matroska container, which browsers will not play. Download them and use +VLC, or convert one without re-encoding: + + ffmpeg -i 20260721-143000.mkv -c copy 20260721-143000.mp4 + +## Adding and editing cameras + +Open **http://localhost:8000/config**, or follow *Set up cameras* from the viewer. + +Add as many cameras as you like. The address decides how each one is handled: a +`http://` address is treated as an MJPEG feed and relayed untouched, while an +`rtsp://` address is decoded by ffmpeg on this machine. + +**Test** pulls a single frame from a camera and reports its resolution, so you can +check an address before you commit to it. It reports the real reason when it fails +too — wrong password, connection refused, nothing listening. + +Cameras are saved to **cameras.json** next to `server.py` and are picked up the next +time the server starts. The viewer notices changes within a few seconds, so you do +not need to reload it — except while a recording is running, when the update politely +waits until the recording stops rather than interrupting it. + +Editing one camera leaves the others alone. Renaming does not restart anything, and a +camera whose address is unchanged keeps streaming without a flicker. + +### Passwords are write-only + +The set-up page shows a saved password as bullets and never receives the real one. +Leave the bullets alone to keep the existing password; type over them to set a new +one. The bullets are never written to disk, so an edit that never saw a password +cannot destroy it. + +### Read-only mode + + python3 server.py --lock-config + +The set-up page still lists the cameras, but saving is refused. Worth using if the +page is reachable by anyone you would not hand the camera passwords to — note that +`--lock-config` only stops edits, it does not add a login. + +Cameras named with `--camera` on the command line win for that run and are not +written to `cameras.json`. + +## Previews on the tabs + +Each camera tab carries a small live preview. + +The camera you are watching updates continuously, drawn from the frame already on +screen, so it costs nothing. Cameras you are **not** watching are not connected, so +their preview is a still that refreshes on a timer. + +That refresh is not free for an RTSP camera — the server has to run ffmpeg briefly to +produce each still — so the rate is yours to choose with the **Previews** button: +5s, 15s (the default), 60s, or off. The choice is remembered per browser, so a phone +and a desktop can disagree about it. Set it to *off* and the previews simply freeze at +the last picture each camera showed. + +## Cameras that came set up + +Camera 1 is the MJPEG feed at `192.168.69.79`. Camera 2 is the RTSP camera at +`192.168.69.128`, already wired to your address. Add or change them on the set-up +page; these are only the starting point. + +Each camera keeps its **own zoom, pan and frame history**. Zoom into one, switch +away, switch back, and it is still framed exactly where you left it. + +The page connects only to the camera you are looking at. Switching away from an RTSP +camera lets the server stop decoding it — recording is unaffected, because that runs +separately and does not decode anything. + +### RTSP needs ffmpeg + +No browser can play RTSP, so the server decodes it with ffmpeg and hands the page +MJPEG. MJPEG cameras need nothing extra. The Docker image already contains ffmpeg; +if you are running `server.py` directly you will need it installed: + + winget install ffmpeg # Windows + sudo apt install ffmpeg # Debian/Ubuntu + brew install ffmpeg # macOS + +Without it, RTSP cameras appear disabled with the reason on screen, and MJPEG +cameras carry on working. + +**Your camera password never reaches the browser.** It stays on the server. The page +is told only `rtsp · 192.168.69.128:554`. + +## Zoom, and what it does to quality + +The camera sends one fixed resolution, so zoom here is a crop rather than a lens. +The page is built so that cropping never costs anything it doesn't have to: + +- Every frame is kept in a master canvas at the camera's native resolution, untouched + and unstamped. The view, photos and recordings are each drawn from it directly, so + no output is ever built from an already-resampled picture. +- The on-screen canvas is backed at your display's real pixel density, so zooming in + reveals detail that was always in the stream but too small to make out. +- Crops are snapped to whole source pixels. A fractional crop boundary would force the + browser to resample even at 1:1. +- **A zoomed photo is a pixel-exact cut of the original.** Zoom 2x into a 1280x720 feed + and you get a 640x360 file whose pixels are identical to that region of the full + frame, rather than a 1280x720 file padded out with interpolated detail. Smaller file, + no invented information. The overlay always shows the exact pixel size you will get. +- The overlay also reports how hard the picture is being magnified to fill the viewport + (`screen 3.4x`). A modest camera on a large monitor is magnified even at 1x, so this + is stated plainly: you can tell when you are looking at real detail and when you are + not. + +### Watching costs a re-encode. Recording does not. + +These are two different paths, and it is worth being clear about which is which. + +**Recording is lossless.** The stream is copied to disk exactly as the camera sent it, +H.265 and all. Nothing is decoded, nothing is re-encoded, nothing is scaled. This is +also why an active camera barely registers on the CPU. + +**Watching an RTSP camera costs one re-encode.** No browser can play RTSP, so ffmpeg +decodes it and hands the page JPEG frames, at `-q:v 3` and full resolution. That +re-encode is unavoidable for anything you want to see in a browser — but it only +happens while somebody is actually looking. + +So the expensive thing is watching, not recording, and it stops when you close the +page. If watching costs more CPU than you would like: + +```bash +python3 server.py --rtsp-quality 6 # smaller JPEGs +python3 server.py --rtsp-fps 10 # fewer frames +python3 server.py --rtsp-size 1280x720 # smaller picture +``` + +Switching the camera URL to `subtype=1` uses the camera's own substream and is usually +the cheapest option of all. It affects what gets recorded too, so use it only if the +substream is good enough to keep. + +**Recorded footage ignores zoom entirely.** Zoom is a viewing control; what reaches +the disk is always the camera's full frame. Zoom and pan as much as you like while a +camera is recording without touching what is saved. + +To turn a recording into MP4 without re-encoding it: + +```bash +ffmpeg -i 20260721-143000.mkv -c copy 20260721-143000.mp4 +``` + +## Credentials + +RTSP URLs usually carry a username and password. Those stay in the server process: +the page is told only `rtsp · 192.168.69.128:554`, and the password appears in no +response the browser ever receives. Passwords containing `!`, `@` or other awkward +characters are safe — nothing is passed through a shell. + +Anyone who can reach the page can watch the cameras, so bind to `127.0.0.1` or keep +it behind your LAN if that matters. + +## Why there's a server + +A browser blocks reading pixels back out of a canvas that has drawn an image from +another origin. That would break both photo capture and recording. `server.py` serves +the page and relays the camera feed on the same origin, so the canvas stays readable. +The relay is also the only thing that talks to the camera — the browser never +connects to port 8080 directly. + +## Notes + +- With `--host 0.0.0.0` (the default) anyone on your LAN who finds the port can watch. + There is no password. Use `--host 127.0.0.1`, or put it behind your reverse proxy, if + that matters. +- Recording captures each frame as it arrives, so the video runs at the camera's own + rate rather than a fixed one. Cameras faster than your monitor's refresh rate will + drop frames; anything up to 30 fps is captured 1:1. +- Photos are taken from the frame already on screen, so they never interrupt the feed. +- If the camera drops out, the page says so and keeps retrying on its own. + +## Running it in the background without Docker + +Docker already does this. If you would rather not use it: + +**Linux (systemd)** + +```ini +# /etc/systemd/system/camera-station.service +[Unit] +Description=Camera Station +After=network-online.target + +[Service] +ExecStart=/usr/bin/python3 /opt/camera-station/server.py --host 0.0.0.0 +WorkingDirectory=/opt/camera-station +Environment=CAMERA_STATION_DATA=/var/lib/camera-station +Restart=always +RestartSec=5 +KillSignal=SIGINT +TimeoutStopSec=20 +User=youruser + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable --now camera-station +``` + +`KillSignal=SIGINT` and the stop timeout matter: they let ffmpeg close the file it is +writing rather than having it cut off. + +**Windows** + +Run it as a scheduled task set to *Run whether user is logged on or not*, triggered +*At startup*, with `pythonw.exe` so no console window appears: + +``` +Program: C:\Path\To\pythonw.exe +Arguments: server.py --host 0.0.0.0 +Start in: C:\Users\JV\Desktop\files +``` diff --git a/config.html b/config.html new file mode 100644 index 0000000..fe05295 --- /dev/null +++ b/config.html @@ -0,0 +1,523 @@ + + + + + +Camera Station · Set up + + + +
+ +
+
+

Camera Station · Set up

+
Loading…
+
+ +
+ +
+ Add as many cameras as you like. An address starting http:// is treated as an + MJPEG feed and relayed untouched; one starting rtsp:// is decoded by ffmpeg on + this machine. Test pulls a single frame so you can check a camera before saving. +
+ + + + + +
+ + +
+ + + + + + +
+ +
+

Stored footage  browse all →

+
+
Reading…
+
+
+ +
+ Recording a camera writes its stream straight to disk without re-encoding, so the + files are exactly what the camera sent and cost very little CPU. Footage is cut into + timestamped pieces; once the size limit is reached the oldest pieces are deleted so + the total never grows past it. +
+ +
+ Saved passwords are shown as bullets and are never sent to this page. Leave the bullets + alone to keep the existing password, or type over them to set a new one. + Cameras are stored in cameras.json next to server.py. +
+ +
+ + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..275bb23 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + camera-station: + build: . + container_name: camera-station + # Comes back after a reboot or a crash, which is what makes this a service + # rather than something you have to remember to start. + restart: unless-stopped + ports: + - "8000:8000" + volumes: + # Recordings, photos and cameras.json. Back this up, not the image. + - ./data:/data + environment: + # Photos and recordings are named with local time, so set your own zone. + # tzdata is installed in the image, so this actually takes effect. + - TZ=Europe/Lisbon + # Recording copies the stream rather than re-encoding it, so this is light. + # Viewing an RTSP camera is the expensive part, because that must decode. + stop_grace_period: 20s # let ffmpeg close the current segment + command: + - python3 + - server.py + - --host + - "0.0.0.0" + - --port + - "8000" + - --storage-limit + - "10" # gigabytes of footage to keep diff --git a/files.zip b/files.zip new file mode 100644 index 0000000..eef7865 Binary files /dev/null and b/files.zip differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..5e2ae9c --- /dev/null +++ b/index.html @@ -0,0 +1,1376 @@ + + + + + + +Camera Station + + + + +
+ +
+
+

Camera

+   +
+
+
+ --:--:-- +   +
+
Connecting
+
+
+ + + +
+ Standby + 00:00:00 +
+ +
+ + + + +
+ +
+ Waiting for signal + Opening the stream. +
+
+ +
+ + + +
+ + +
+
+ +
+
Resolution
+
+
+
Frame rate
+ last 60s +
+
+ +
+
Uptime
00:00:00
+
Recorded
+
Photos
0
+
+ +
+

Photos saved on the server

+
+

Photos are stored on the server as date_time_camera. This list clears when you reload; the files do not.

+
+
+ +
+ R record  ·  Space photo  ·  + 1 2 switch camera  ·  + + zoom, 0 fit, arrows pan  ·  + Recording runs on the server and continues when this page is closed. + Photos are saved on the server automatically. +
+ +
+ + + + diff --git a/library.html b/library.html new file mode 100644 index 0000000..32c37d4 --- /dev/null +++ b/library.html @@ -0,0 +1,443 @@ + + + + + +Camera Station · Files + + + +
+ +
+
+

Camera Station · Files

+
Loading…
+
+ +
+ +
+ + +
+ +
+
+
Reading…
+
+ +
+ + + + + + +
+ +
+ Recordings are Matroska files holding the camera's own stream, so most browsers + cannot play them in a tab. Download and open them in VLC, or convert one + without re-encoding: ffmpeg -i file.mkv -c copy file.mp4 +
+ +
+
+ +
+ + + + diff --git a/server.py b/server.py new file mode 100644 index 0000000..ba2d197 --- /dev/null +++ b/server.py @@ -0,0 +1,1585 @@ +#!/usr/bin/env python3 +""" +Camera Station — a tiny self-hosted front end for MJPEG and RTSP cameras. + +Why a server instead of just opening the HTML file? + + 1. Browsers "taint" a that has drawn an image from another origin, + and a tainted canvas cannot be read back — which would break both photo + capture and recording. Serving the page and the video from one origin + keeps the canvas readable. + 2. No browser can play RTSP. RTSP cameras are decoded by ffmpeg here and + handed to the page as MJPEG, so every camera behaves the same way. + +Camera credentials stay in this process and are never sent to the page. + +Usage: + python3 server.py + python3 server.py --camera "Front door=http://192.168.69.79:8080" + python3 server.py --camera "Drive=rtsp://user:pass@192.168.69.128:554/cam" + python3 server.py --host 127.0.0.1 # only this machine can connect + +Then open http://localhost:8000 +Standard library only. ffmpeg is needed for RTSP cameras, nothing else. +""" + +import argparse +import json +import os +import re +import shutil +import socket +import subprocess +import sys +import threading +import time +import uuid +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from queue import Queue, Empty +from urllib.error import URLError, HTTPError +from urllib.parse import urlparse, parse_qs, quote, unquote +from urllib.request import urlopen + +HERE = os.path.dirname(os.path.abspath(__file__)) +PAGE = os.path.join(HERE, "index.html") +CONFIG_PAGE = os.path.join(HERE, "config.html") +LIBRARY_PAGE = os.path.join(HERE, "library.html") + +# Everything the service produces lives under one directory, so a container can +# mount a single volume and keep recordings, photos and settings together. +DATA_DIR = os.environ.get("CAMERA_STATION_DATA") or os.path.join(HERE, "data") +CONFIG_FILE = os.path.join(DATA_DIR, "cameras.json") +LEGACY_CONFIG = os.path.join(HERE, "cameras.json") +RECORD_DIR = os.path.join(DATA_DIR, "recordings") +PHOTO_DIR = os.path.join(DATA_DIR, "photos") + +STORAGE_LIMIT = 10 * 1024 ** 3 # 10 GB of footage, oldest deleted first +SEGMENT_SECONDS = 600 # a new file every 10 minutes, at a keyframe +SWEEP_SECONDS = 20 # how often storage is checked + + +def ensure_dirs(): + for path in (DATA_DIR, RECORD_DIR, PHOTO_DIR): + os.makedirs(path, exist_ok=True) + + +def slug(text): + out = re.sub(r"[^A-Za-z0-9]+", "-", (text or "").strip()).strip("-") + return out or "camera" + +DEFAULT_CAMERAS = [ + ("Camera 1", "http://192.168.69.79:8080"), + ("Camera 2", "rtsp://admin:Admin100pass!@192.168.69.128:554" + "/cam/realmonitor?channel=1&subtype=0"), +] + +BOUNDARY = "camerastation" + + +# -------------------------------------------------------------------------- +# helpers +# -------------------------------------------------------------------------- + +def recording_state(cam): + rec = getattr(cam, "recorder", None) + status = rec.status() if rec else {} + return {"active": bool(getattr(cam, "active", False)), + "recording": bool(status.get("running")), + "since": status.get("since"), + "recordError": status.get("error")} + + +def safe_host(url): + """The address with any user:password stripped, for display in the page.""" + try: + p = urlparse(url) + host = p.hostname or "" + if p.port: + host += ":%d" % p.port + return host or "camera" + except ValueError: + return "camera" + + +def ffmpeg_available(): + return shutil.which("ffmpeg") is not None + + +def ffmpeg_timeout_flag(): + """ffmpeg 6 renamed the RTSP socket timeout from -stimeout to -timeout.""" + try: + out = subprocess.run(["ffmpeg", "-version"], capture_output=True, + text=True, timeout=5).stdout + m = re.search(r"ffmpeg version n?(\d+)", out) + if m and int(m.group(1)) >= 6: + return "-timeout" + except Exception: + pass + return "-stimeout" + + +# The config page has to show existing cameras without handing their passwords +# to a browser, so a saved password is displayed as bullets and only travels in +# one direction: you can set a new one, but you can never read the old one back. +KEPT_SECRET = "\u2022" * 8 +URL_PARTS = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.\-]*://)([^/@]*@)?(.*)$", re.S) + + +def redact_url(url): + """Replace any password in a URL with bullets.""" + m = URL_PARTS.match(url or "") + if not m: + return url + scheme, userinfo, rest = m.group(1), m.group(2) or "", m.group(3) + creds = userinfo[:-1] + if ":" not in creds: + return url + return scheme + creds.split(":", 1)[0] + ":" + KEPT_SECRET + "@" + rest + + +def restore_url(url, previous): + """Put the stored password back if the page sent the bullets untouched.""" + m = URL_PARTS.match(url or "") + if not m: + return url + scheme, userinfo, rest = m.group(1), m.group(2) or "", m.group(3) + creds = userinfo[:-1] + if ":" not in creds: + return url + user, password = creds.split(":", 1) + if password != KEPT_SECRET: + return url # a new password was typed; use it + prev = URL_PARTS.match(previous or "") + if prev and prev.group(2) and ":" in prev.group(2)[:-1]: + old = prev.group(2)[:-1].split(":", 1)[1] + return scheme + user + ":" + old + "@" + rest + return scheme + user + "@" + rest # nothing stored to restore + + +def valid_url(url): + url = (url or "").strip() + return url.startswith(("http://", "https://", "rtsp://", "rtsps://")) + + +def load_config(): + """Cameras saved by the config page, or None if there are none yet.""" + data = None + for path in (CONFIG_FILE, LEGACY_CONFIG): + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + break + except (OSError, ValueError): + continue + if data is None: + return None + entries = data.get("cameras") if isinstance(data, dict) else data + if not isinstance(entries, list): + return None + out = [] + for e in entries: + if isinstance(e, dict) and valid_url(e.get("url")): + out.append({"id": str(e.get("id") or new_id()), + "name": str(e.get("name") or "Camera").strip() or "Camera", + "url": e["url"].strip(), + "active": bool(e.get("active"))}) + return out or None + + +def save_config(entries): + """Write the camera list, via a temporary file so a crash cannot truncate it.""" + ensure_dirs() + tmp = CONFIG_FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump({"cameras": entries}, fh, indent=2) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, CONFIG_FILE) + + +def new_id(): + return "cam" + uuid.uuid4().hex[:6] + + +def jpeg_size(data): + """Width and height from JPEG headers, without pulling in an image library.""" + i, n = 2, len(data) + while i + 9 < n: + if data[i] != 0xFF: + i += 1 + continue + marker = data[i + 1] + if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7: + i += 2 + continue + if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, + 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF): + return (int.from_bytes(data[i + 7:i + 9], "big"), + int.from_bytes(data[i + 5:i + 7], "big")) + i += 2 + int.from_bytes(data[i + 2:i + 4], "big") + return None + + +# -------------------------------------------------------------------------- +# cameras +# -------------------------------------------------------------------------- + +class MjpegCamera: + """An MJPG-Streamer style feed. Relayed byte for byte — nothing re-encoded.""" + + kind = "mjpeg" + + def __init__(self, cid, name, url): + self.id = cid + self.name = name + self.url = url.rstrip("/") + self.available = True + self.reason = None + + def describe(self): + return dict(recording_state(self), + id=self.id, name=self.name, kind=self.kind, + host=safe_host(self.url), available=self.available, + reason=self.reason) + + def open_upstream(self, action): + return urlopen(self.url + "/?action=" + action, timeout=10) + + +class RtspCamera: + """ + An RTSP camera, decoded by ffmpeg and re-published as MJPEG. + + One ffmpeg process per camera, shared by every viewer, started on the first + subscriber and stopped shortly after the last one leaves. Decoding an H.264 + stream twice because two tabs are open would be wasteful, and most cameras + limit how many RTSP sessions they will serve at once. + """ + + kind = "rtsp" + IDLE_GRACE = 8.0 # seconds to keep ffmpeg alive after the last viewer + + # ffmpeg is chatty about things that are not faults. Two kinds dominate: + # + # * Decoder complaints while it waits for the first keyframe. Joining an + # H.264/HEVC stream mid-GOP means the first frames reference pictures + # that were sent before we connected. This clears itself the moment an + # IDR arrives, usually within a second or two. + # * Broken pipe, on the way down. When a camera is released, its stdout is + # closed, and ffmpeg reports every stage of noticing that. + # + # Neither says anything useful about whether the camera works, and treating + # them as errors puts nonsense like "Error closing file" on screen as the + # reason a camera is unavailable. + DECODER_NOISE = ( + "could not find ref with poc", + "error constructing the frame rps", + "missing reference picture", + "reference picture missing", + "co located pocs unavailable", + "decode_slice_header error", + "no frame!", + "mmco: unref short failure", + "illegal short term buffer state", + "error while decoding mb", + "concealing", + "corrupt decoded frame", + "non-existing pps", + "sps unavailable", + "deprecated pixel format", + "last message repeated", + ) + SHUTDOWN_NOISE = ( + "broken pipe", + "error muxing a packet", + "task finished with error code", + "terminating thread with return code", + "error writing trailer", + "error closing file", + "error submitting a packet to the muxer", + ) + + @classmethod + def is_noise(cls, text): + low = text.lower() + return any(p in low for p in cls.DECODER_NOISE + cls.SHUTDOWN_NOISE) + + def __init__(self, cid, name, url, quality=3, fps=None, size=None, verbose=False): + self.id = cid + self.name = name + self.url = url + self.quality = quality + self.fps = fps + self.size = size + self.verbose = verbose + self.noise_count = 0 + + self.available = ffmpeg_available() + self.reason = None if self.available else \ + "ffmpeg is not installed, so this RTSP camera cannot be decoded." + + self._lock = threading.RLock() + self._subs = set() + self._proc = None + self._stopper = None + self._latest = None + self._latest_at = 0 + self._first_frame = threading.Event() + self._timeout_flag = ffmpeg_timeout_flag() if self.available else None + self._last_error = None + + # ---- description ---- + + def describe(self): + return dict(recording_state(self), + id=self.id, name=self.name, kind=self.kind, + host=safe_host(self.url), available=self.available, + reason=self.reason or self._last_error) + + # ---- process control ---- + + def input_args(self, timeout_flag): + """Overridable so tests can substitute a synthetic source.""" + return [timeout_flag, "5000000", "-rtsp_transport", "tcp", "-i", self.url] + + def _command(self, timeout_flag): + args = ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", + "-fflags", "nobuffer+discardcorrupt"] + args += self.input_args(timeout_flag) + args += ["-an"] + if self.fps: + args += ["-r", str(self.fps)] + if self.size: + args += ["-s", self.size] + args += ["-f", "mpjpeg", "-q:v", str(self.quality), "-"] + return args + + def _spawn(self): + """Start ffmpeg, retrying once if the timeout flag is the wrong spelling.""" + alt = "-stimeout" if self._timeout_flag == "-timeout" else "-timeout" + proc = None + for flag in (self._timeout_flag, alt): + proc = subprocess.Popen(self._command(flag), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0) + time.sleep(0.3) + if proc.poll() is None: + self._timeout_flag = flag + return proc + err = (proc.stderr.read() or b"").decode("utf-8", "replace") + if "Unrecognized option" not in err and "Option not found" not in err: + lines = [l for l in err.strip().splitlines() if l.strip()] + self._last_error = lines[-1] if lines else "ffmpeg exited immediately." + return proc + return proc + + def _ensure_running(self): + with self._lock: + if self._stopper: + self._stopper.cancel() + self._stopper = None + if self._proc and self._proc.poll() is None: + return + self._first_frame.clear() + self._proc = self._spawn() + threading.Thread(target=self._pump, args=(self._proc,), daemon=True).start() + threading.Thread(target=self._drain_errors, args=(self._proc,), daemon=True).start() + + def _schedule_stop(self): + with self._lock: + if self._stopper: + self._stopper.cancel() + self._stopper = threading.Timer(self.IDLE_GRACE, self._stop_if_idle) + self._stopper.daemon = True + self._stopper.start() + + def _stop_if_idle(self): + with self._lock: + if self._subs: + return + proc, self._proc = self._proc, None + self._latest = None + if proc and proc.poll() is None: + proc.cs_stopping = True + proc.terminate() + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + + def shutdown(self): + """Stop for good, even if someone is still watching (config changed).""" + with self._lock: + self._subs.clear() + self._stop_if_idle() + + def _drain_errors(self, proc): + try: + for line in iter(proc.stderr.readline, b""): + text = line.decode("utf-8", "replace").strip() + if not text: + continue + if getattr(proc, "cs_stopping", False): + # We asked this process to stop. Whatever it says on the way + # out is expected, so it is not worth alarming anyone with. + continue + if self.is_noise(text): + self.noise_count += 1 + if self.verbose: + sys.stderr.write(" [%s] %s\n" % (self.id, text)) + continue + self._last_error = text + sys.stderr.write(" [%s] %s\n" % (self.id, text)) + except Exception: + pass + + # ---- mpjpeg parsing ---- + + def _pump(self, proc): + """ + Read ffmpeg's multipart output and hand whole JPEG frames to viewers. + + Frames are split on the Content-length header rather than by hunting for + JPEG end markers, because those bytes can occur inside compressed data. + """ + out = proc.stdout + try: + while True: + line = out.readline() + if not line: + break + low = line.strip().lower() + if not low.startswith(b"content-length"): + continue + try: + size = int(low.split(b":", 1)[1]) + except (IndexError, ValueError): + continue + while True: # skip to the blank separator + sep = out.readline() + if not sep or sep in (b"\r\n", b"\n"): + break + frame = out.read(size) + if not frame or len(frame) < size: + break + self._publish(frame) + except Exception as err: + self._last_error = str(err) + finally: + try: + out.close() + except Exception: + pass + + def _publish(self, frame): + # Pictures are arriving, so any earlier complaint is history. + if self._last_error is not None: + self._last_error = None + self._latest = frame + self._latest_at = time.time() + self._first_frame.set() + for q in list(self._subs): + if q.full(): + try: + q.get_nowait() # drop the stale frame, never the fresh one + except Empty: + pass + try: + q.put_nowait(frame) + except Exception: + pass + + # ---- viewer API ---- + + def subscribe(self): + q = Queue(maxsize=1) + with self._lock: + self._subs.add(q) + self._ensure_running() + return q + + def unsubscribe(self, q): + with self._lock: + self._subs.discard(q) + idle = not self._subs + if idle: + self._schedule_stop() + + def snapshot(self, wait=10.0): + """Latest decoded frame, starting the camera briefly if nobody is watching.""" + if self._latest is not None and time.time() - self._latest_at < 5: + return self._latest + q = self.subscribe() + try: + self._first_frame.wait(wait) + return self._latest + finally: + self.unsubscribe(q) + + def viewers(self): + with self._lock: + return len(self._subs) + + def is_running(self): + with self._lock: + return bool(self._proc and self._proc.poll() is None) + + + +# -------------------------------------------------------------------------- +# continuous recording +# -------------------------------------------------------------------------- + +class Recorder: + """ + Writes a camera to disk continuously while it is marked active. + + The stream is copied, not re-encoded: what lands on disk is exactly the + bitstream the camera sent, at no quality cost and almost no CPU. That also + means an active camera costs nearly nothing until somebody actually watches + it, because only watching requires decoding. + + Footage is cut into timestamped segments so old material can be deleted a + piece at a time. Matroska is used rather than MP4 because a segment that is + still being written stays readable if the service is stopped or the machine + loses power; an unfinalised MP4 would not. + """ + + RESTART_DELAY = 5.0 + + def __init__(self, cam): + self.cam = cam + self.proc = None + self.started_at = 0 + self.error = None + self.folder_name = slug(cam.name) + self._lock = threading.RLock() + self._last_attempt = 0 + + @property + def folder(self): + return os.path.join(RECORD_DIR, self.folder_name) + + def is_running(self): + with self._lock: + return bool(self.proc and self.proc.poll() is None) + + def command(self): + args = ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error"] + if self.cam.kind == "rtsp": + args += [ffmpeg_timeout_flag(), "5000000", + "-rtsp_transport", "tcp", "-i", self.cam.url] + else: + # An HTTP MJPEG feed carries no timestamps of its own, so take them + # from the clock; without this the file plays back at the wrong speed. + args += ["-use_wallclock_as_timestamps", "1", + "-f", "mjpeg", "-i", self.cam.url + "/?action=stream"] + args += ["-an", "-c", "copy", + "-f", "segment", "-strftime", "1", + "-segment_time", str(SEGMENT_SECONDS), + "-segment_format", "matroska", + "-reset_timestamps", "1", + os.path.join(self.folder, "%Y%m%d-%H%M%S.mkv")] + return args + + def start(self): + with self._lock: + if self.is_running(): + return + if time.time() - self._last_attempt < self.RESTART_DELAY: + return # a camera that keeps failing is not hammered + self._last_attempt = time.time() + if not ffmpeg_available(): + self.error = "ffmpeg is not installed, so nothing can be recorded." + return + os.makedirs(self.folder, exist_ok=True) + try: + self.proc = subprocess.Popen(self.command(), stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, bufsize=0) + except OSError as err: + self.error = str(err) + return + self.started_at = time.time() + threading.Thread(target=self._watch, args=(self.proc,), daemon=True).start() + + def stop(self): + with self._lock: + proc, self.proc = self.proc, None + self.started_at = 0 + if proc and proc.poll() is None: + proc.cs_stopping = True + proc.terminate() # ffmpeg closes the current segment cleanly + try: + proc.wait(timeout=8) + except subprocess.TimeoutExpired: + proc.kill() + + def rename(self, name): + """Follow a camera being renamed, carrying its footage across.""" + wanted = slug(name) + with self._lock: + if wanted == self.folder_name: + return + running = self.is_running() + if running: + self.stop() + old, new = self.folder, os.path.join(RECORD_DIR, wanted) + try: + if os.path.isdir(old) and not os.path.exists(new): + os.rename(old, new) + except OSError: + pass # leave the old footage where it is + self.folder_name = wanted + if running: + self._last_attempt = 0 + self.start() + + def _watch(self, proc): + for line in iter(proc.stderr.readline, b""): + text = line.decode("utf-8", "replace").strip() + if not text or getattr(proc, "cs_stopping", False): + continue + if RtspCamera.is_noise(text): + continue + self.error = text + sys.stderr.write(" [rec %s] %s\n" % (self.cam.name, text)) + code = proc.wait() + if not getattr(proc, "cs_stopping", False) and code not in (0, None): + sys.stderr.write(" [rec %s] recorder stopped (code %s); will retry\n" + % (self.cam.name, code)) + + def status(self): + return {"running": self.is_running(), + "since": self.started_at or None, + "folder": self.folder_name, + "error": None if self.is_running() else self.error} + + +def recording_files(): + """Every stored segment, oldest first.""" + found = [] + for root, _dirs, names in os.walk(RECORD_DIR): + for name in names: + if not name.endswith(".mkv"): + continue + path = os.path.join(root, name) + try: + st = os.stat(path) + except OSError: + continue + found.append({"path": path, + "folder": os.path.basename(root), + "name": name, + "size": st.st_size, + "mtime": st.st_mtime}) + found.sort(key=lambda f: f["mtime"]) + return found + + +def enforce_storage_limit(limit=None): + """ + Delete the oldest footage until the total is back under the limit. + + The newest file in each camera's folder is left alone even if that means + briefly exceeding the limit: it is the one being written to right now. + """ + limit = STORAGE_LIMIT if limit is None else limit + files = recording_files() + total = sum(f["size"] for f in files) + if total <= limit: + return {"used": total, "deleted": 0, "freed": 0} + + newest_per_folder = {} + for f in files: + newest_per_folder[f["folder"]] = f["path"] # sorted oldest first + protected = set(newest_per_folder.values()) + + deleted, freed = 0, 0 + for f in files: + if total <= limit: + break + if f["path"] in protected: + continue + try: + os.remove(f["path"]) + except OSError: + continue + total -= f["size"] + freed += f["size"] + deleted += 1 + return {"used": total, "deleted": deleted, "freed": freed} + + +def storage_summary(): + files = recording_files() + folders = {} + for f in files: + entry = folders.setdefault(f["folder"], {"folder": f["folder"], "bytes": 0, + "segments": 0, "oldest": None, + "newest": None}) + entry["bytes"] += f["size"] + entry["segments"] += 1 + entry["oldest"] = entry["oldest"] or f["mtime"] + entry["newest"] = f["mtime"] + photos = 0 + photo_count = 0 + try: + for name in os.listdir(PHOTO_DIR): + try: + photos += os.path.getsize(os.path.join(PHOTO_DIR, name)) + photo_count += 1 + except OSError: + pass + except OSError: + pass + recent = [{"folder": f["folder"], "name": f["name"], "size": f["size"], + "mtime": f["mtime"]} for f in files[-12:]][::-1] + return {"limit": STORAGE_LIMIT, + "used": sum(f["size"] for f in files), + "segments": len(files), + "photoBytes": photos, + "photoCount": photo_count, + "cameras": sorted(folders.values(), key=lambda d: -d["bytes"]), + "recent": recent} + + +PHOTO_NAME = re.compile(r"^(\d{8})_(\d{6})_(.+?)(?:-(\d+))?\.(png|jpe?g)$", re.I) +SEGMENT_NAME = re.compile(r"^(\d{8})-(\d{6})\.mkv$") + + +def pretty_time(day, hhmmss): + return "%s-%s-%s %s:%s:%s" % (day[0:4], day[4:6], day[6:8], + hhmmss[0:2], hhmmss[2:4], hhmmss[4:6]) + + +def files_being_written(): + """Segments an active recorder is writing into right now.""" + busy = set() + by_folder = {} + for f in recording_files(): + by_folder[f["folder"]] = f["path"] # oldest first, so this ends newest + for cam in CAMERAS: + rec = getattr(cam, "recorder", None) + if rec and rec.is_running(): + path = by_folder.get(rec.folder_name) + if path: + busy.add(path) + return busy + + +def recording_entries(): + """Every stored segment, newest first, with the time taken from its name.""" + out = [] + live = files_being_written() + for f in recording_files(): + m = SEGMENT_NAME.match(f["name"]) + day = m.group(1) if m else time.strftime("%Y%m%d", time.localtime(f["mtime"])) + clock = m.group(2) if m else time.strftime("%H%M%S", time.localtime(f["mtime"])) + out.append({"kind": "recording", "name": f["name"], "camera": f["folder"], + "day": day, "when": pretty_time(day, clock), + "size": f["size"], "mtime": f["mtime"], + # Still being written to. It can be downloaded, but it is + # only as complete as it was the moment you asked. + "live": f["path"] in live, + "url": "/recordings/%s/%s" % (quote(f["folder"]), quote(f["name"]))}) + out.reverse() + return out + + +def photo_entries(): + """Photos, newest first. The camera and time come out of the filename.""" + out = [] + try: + names = os.listdir(PHOTO_DIR) + except OSError: + return out + for name in names: + path = os.path.join(PHOTO_DIR, name) + try: + st = os.stat(path) + except OSError: + continue + if not os.path.isfile(path): + continue + m = PHOTO_NAME.match(name) + if m: + day, clock, camera = m.group(1), m.group(2), m.group(3) + when = pretty_time(day, clock) + else: + day = time.strftime("%Y%m%d", time.localtime(st.st_mtime)) + clock = time.strftime("%H%M%S", time.localtime(st.st_mtime)) + camera, when = "unknown", pretty_time(day, clock) + out.append({"kind": "photo", "name": name, "camera": camera, + "day": day, "when": when, "size": st.st_size, + "mtime": st.st_mtime, "url": "/photos/" + quote(name)}) + out.sort(key=lambda e: (e["day"], e["name"]), reverse=True) + return out + + +def browse(kind, camera=None, day=None, offset=0, limit=300): + entries = photo_entries() if kind == "photos" else recording_entries() + cameras = sorted({e["camera"] for e in entries}) + days = sorted({e["day"] for e in entries}, reverse=True) + if camera: + entries = [e for e in entries if e["camera"] == camera] + if day: + entries = [e for e in entries if e["day"] == day] + total = len(entries) + shown = entries[offset:offset + limit] + return {"kind": kind, "cameras": cameras, "days": days, + "total": total, "offset": offset, + "bytes": sum(e["size"] for e in entries), + "files": shown} + + +def delete_files(kind, names): + """ + Remove stored files. + + Paths are rebuilt from the root rather than trusted, and the segment an + active recorder is writing into is refused: deleting it would not stop the + recording, it would just lose the footage being captured. + """ + root = PHOTO_DIR if kind == "photos" else RECORD_DIR + busy = files_being_written() if kind != "photos" else set() + deleted, freed, skipped = 0, 0, [] + for rel in names: + target = os.path.normpath(os.path.join(root, rel.lstrip("/"))) + if not target.startswith(os.path.normpath(root) + os.sep): + skipped.append(rel) + continue + if target in busy: + skipped.append(rel) + continue + try: + size = os.path.getsize(target) + os.remove(target) + except OSError: + skipped.append(rel) + continue + deleted += 1 + freed += size + return {"deleted": deleted, "freed": freed, "skipped": skipped} + + +def supervise(): + """ + Keep active cameras recording and storage inside its limit. + + A camera that drops off the network takes its recorder down with it, so the + recorders are restarted from here rather than only when something asks. + """ + while True: + try: + for cam in list(CAMERAS): + rec = getattr(cam, "recorder", None) + if rec is None: + continue + if getattr(cam, "active", False) and cam.available: + if not rec.is_running(): + rec.start() + elif rec.is_running(): + rec.stop() + enforce_storage_limit() + except Exception as err: # never let the loop die + sys.stderr.write(" supervisor: %s\n" % err) + time.sleep(SWEEP_SECONDS) + + +# -------------------------------------------------------------------------- +# http +# -------------------------------------------------------------------------- + +CAMERAS = [] +LOCKED = False # --lock-config disables editing + + +class Options: + """Decoding settings applied to every RTSP camera.""" + rtsp_quality = 3 + rtsp_fps = None + rtsp_size = None + verbose = False + + +OPTS = Options() + + +def make_camera(cid, name, url, opts=None): + # Read settings defensively: this is called with the parsed command line, + # with OPTS, and from tests, and a missing field should fall back to the + # default rather than stop a camera from being created at all. + opts = opts or OPTS + if url.startswith(("rtsp://", "rtsps://")): + cam = RtspCamera(cid, name, url, + quality=getattr(opts, "rtsp_quality", 3), + fps=getattr(opts, "rtsp_fps", None), + size=getattr(opts, "rtsp_size", None), + verbose=getattr(opts, "verbose", False)) + else: + cam = MjpegCamera(cid, name, url) + cam.active = False + cam.recorder = Recorder(cam) + return cam + + +def apply_config(entries): + """ + Swap in a new camera list without disturbing cameras that did not change. + + Rebuilding everything on every save would drop the picture on cameras the + edit never touched, and restart an ffmpeg decode for no reason, so cameras + whose address is unchanged are carried over as they are. + """ + previous = {c.id: c for c in CAMERAS} + rebuilt, kept = [], set() + for entry in entries: + cid = entry.get("id") or new_id() + name = entry["name"] + url = entry["url"] + old = previous.get(cid) + # MjpegCamera stores its address with any trailing slash removed, so + # compare against both spellings before deciding it has changed. + unchanged = old is not None and getattr(old, "url", None) in (url, url.rstrip("/")) + if unchanged: + cam = old + if cam.name != name: + cam.name = name + cam.recorder.rename(name) # take the footage folder with it + else: + cam = make_camera(cid, name, url) + cam.active = bool(entry.get("active")) + rebuilt.append(cam) + kept.add(id(cam)) + + for cam in CAMERAS: + if id(cam) in kept: + continue + if getattr(cam, "recorder", None): + cam.recorder.stop() # dropped from the config, so stop recording it + if isinstance(cam, RtspCamera): + cam.shutdown() # released, so stop decoding it + + CAMERAS[:] = rebuilt + return CAMERAS + + +def config_entries(): + """The current cameras, safe to hand to the config page.""" + return [dict(recording_state(c), id=c.id, name=c.name, url=redact_url(c.url)) + for c in CAMERAS] + + +def persist(): + """Write the current cameras back to disk, keeping real passwords.""" + save_config([{"id": c.id, "name": c.name, "url": c.url, + "active": bool(getattr(c, "active", False))} for c in CAMERAS]) + + +def probe(url, timeout=15): + """ + Try to pull a single frame, so a camera can be checked before it is saved. + """ + url = (url or "").strip() + if not valid_url(url): + return {"ok": False, "message": "Address must start with http:// or rtsp://"} + + if url.startswith(("rtsp://", "rtsps://")): + if not ffmpeg_available(): + return {"ok": False, "kind": "rtsp", + "message": "ffmpeg is not installed, so RTSP cameras cannot be used."} + args = ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", + ffmpeg_timeout_flag(), str(int(timeout * 1000000)), + "-rtsp_transport", "tcp", "-i", url, + "-frames:v", "1", "-f", "mjpeg", "-q:v", "3", "-"] + try: + done = subprocess.run(args, capture_output=True, timeout=timeout + 5) + except subprocess.TimeoutExpired: + return {"ok": False, "kind": "rtsp", + "message": "No response within %ds." % timeout} + if done.stdout[:3] == b"\xff\xd8\xff": + size = jpeg_size(done.stdout) + return {"ok": True, "kind": "rtsp", + "width": size[0] if size else None, + "height": size[1] if size else None} + err = [l for l in done.stderr.decode("utf-8", "replace").splitlines() + if l.strip() and not RtspCamera.is_noise(l)] + return {"ok": False, "kind": "rtsp", + "message": err[-1] if err else "No picture came back."} + + base = url.rstrip("/") + for candidate in (base + "/?action=snapshot", base): + try: + with urlopen(candidate, timeout=timeout) as r: + head = r.read(600000) + except (URLError, HTTPError, socket.timeout, OSError) as err: + message = str(getattr(err, "reason", err)) + continue + start = head.find(b"\xff\xd8\xff") + if start >= 0: + size = jpeg_size(head[start:]) + return {"ok": True, "kind": "mjpeg", + "width": size[0] if size else None, + "height": size[1] if size else None} + message = "Reachable, but no JPEG was found in the reply." + return {"ok": False, "kind": "mjpeg", "message": message} + + +def find_camera(cid): + for cam in CAMERAS: + if cam.id == cid: + return cam + return None + + +class Station(ThreadingHTTPServer): + """ + A threading server that stays quiet about viewers leaving. + + Long-lived MJPEG responses are aborted every time someone switches camera, + reloads, or closes a tab. That is routine, not a fault, so it should not + print a stack trace. Anything else still gets reported normally. + """ + + daemon_threads = True + allow_reuse_address = True + + def handle_error(self, request, client_address): + if isinstance(sys.exc_info()[1], (ConnectionError, socket.timeout)): + return + super().handle_error(request, client_address) + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "CameraStation/2.0" + + def do_GET(self): + parsed = urlparse(self.path) + path = parsed.path + query = parse_qs(parsed.query) + + if path in ("/", "/index.html"): + self.serve_page() + return + if path == "/cameras": + self.serve_json([c.describe() for c in CAMERAS]) + return + if path in ("/config", "/config.html"): + self.serve_file(CONFIG_PAGE, "text/html; charset=utf-8") + return + if path == "/api/config": + self.serve_json({"cameras": config_entries(), "locked": LOCKED, + "ffmpeg": ffmpeg_available()}) + return + if path == "/api/storage": + self.serve_json(storage_summary()) + return + if path in ("/files", "/library", "/files.html"): + self.serve_file(LIBRARY_PAGE, "text/html; charset=utf-8") + return + if path == "/api/files": + kind = (query.get("kind") or ["recordings"])[0] + kind = "photos" if kind == "photos" else "recordings" + def one(key): + value = (query.get(key) or [""])[0].strip() + return value or None + try: + offset = max(0, int((query.get("offset") or ["0"])[0])) + except ValueError: + offset = 0 + result = browse(kind, one("camera"), one("day"), offset) + result["limit"] = STORAGE_LIMIT + result["used"] = sum(f["size"] for f in recording_files()) + self.serve_json(result) + return + if path.startswith("/recordings/") or path.startswith("/photos/"): + self.serve_stored(path, (query.get("download") or [""])[0] == "1") + return + + if path in ("/stream", "/snapshot"): + cid = (query.get("cam") or [None])[0] + cam = find_camera(cid) if cid else (CAMERAS[0] if CAMERAS else None) + if cam is None: + self.fail(404, "No camera named %s" % cid) + return + if path == "/stream": + self.serve_stream(cam) + else: + self.serve_snapshot(cam) + return + + self.fail(404, "Nothing is served at %s" % path) + + def do_POST(self): + parsed = urlparse(self.path) + path = parsed.path + try: + length = int(self.headers.get("Content-Length") or 0) + except ValueError: + length = 0 + + if path == "/api/photo": + self.save_photo(parse_qs(parsed.query), length) + return + if length > 1000000: + self.fail(413, "That is too much data.") + return + try: + payload = json.loads(self.rfile.read(length) or b"{}") + except ValueError: + self.fail(400, "Could not read that as JSON.") + return + + if path == "/api/test": + self.serve_json(probe(payload.get("url", ""))) + return + + if path == "/api/active": + cam = find_camera(str(payload.get("id") or "")) + if cam is None: + self.fail(404, "No such camera.") + return + if LOCKED: + self.fail(403, "Recording cannot be changed while --lock-config is set.") + return + cam.active = bool(payload.get("active")) + if cam.active: + cam.recorder.start() + else: + cam.recorder.stop() + try: + persist() + except OSError as err: + self.fail(500, "Could not save that (%s)" % err) + return + self.serve_json(dict(recording_state(cam), id=cam.id)) + return + + if path == "/api/delete": + if LOCKED: + self.fail(403, "Files cannot be deleted while --lock-config is set.") + return + kind = "photos" if payload.get("kind") == "photos" else "recordings" + names = payload.get("names") + if not isinstance(names, list) or not names: + self.fail(400, "Nothing was selected.") + return + if len(names) > 5000: + self.fail(400, "That is too many files at once.") + return + self.serve_json(delete_files(kind, [str(n) for n in names])) + return + + if path == "/api/config": + if LOCKED: + self.fail(403, "Editing is disabled because the server was " + "started with --lock-config.") + return + self.save_cameras(payload) + return + + self.fail(404, "Nothing accepts a POST at %s" % path) + + def save_photo(self, query, length): + """ + Store a picture taken in the page. + + The browser sends the image rather than the server grabbing its own, + because the page may be zoomed in: what gets saved is the exact framing + on screen, cut from the original pixels. + """ + if length <= 0 or length > 80 * 1024 * 1024: + self.fail(400, "No image, or one too large to be a photo.") + return + cam = find_camera((query.get("cam") or [""])[0]) + if cam is None: + self.fail(404, "No such camera.") + return + ctype = (self.headers.get("Content-Type") or "").lower() + ext = "jpg" if "jpeg" in ctype else "png" + body = self.rfile.read(length) + if not (body[:3] == b"\xff\xd8\xff" or body[:8] == b"\x89PNG\r\n\x1a\n"): + self.fail(400, "That did not arrive as a PNG or JPEG.") + return + + ensure_dirs() + stamp = time.strftime("%Y%m%d_%H%M%S") + name = "%s_%s.%s" % (stamp, slug(cam.name), ext) + target = os.path.join(PHOTO_DIR, name) + # Two photos inside one second should not overwrite each other. + n = 2 + while os.path.exists(target): + name = "%s_%s-%d.%s" % (stamp, slug(cam.name), n, ext) + target = os.path.join(PHOTO_DIR, name) + n += 1 + try: + with open(target, "wb") as fh: + fh.write(body) + except OSError as err: + self.fail(500, "Could not save the photo (%s)" % err) + return + self.serve_json({"ok": True, "name": name, + "url": "/photos/" + quote(name), "bytes": len(body)}) + + def save_cameras(self, payload): + incoming = payload.get("cameras") + if not isinstance(incoming, list): + self.fail(400, "Expected a list of cameras.") + return + + known = {c.id: c.url for c in CAMERAS} + entries, seen = [], set() + for i, item in enumerate(incoming): + if not isinstance(item, dict): + self.fail(400, "Camera %d is not readable." % (i + 1)) + return + name = str(item.get("name") or "").strip() or "Camera %d" % (i + 1) + url = str(item.get("url") or "").strip() + cid = str(item.get("id") or "").strip() or new_id() + # The page never receives passwords, so bullets left in place mean + # "keep the one you already have". + url = restore_url(url, known.get(cid)) + if not valid_url(url): + self.fail(400, "%s needs an address starting http:// or rtsp://" % name) + return + if cid in seen: + cid = new_id() + seen.add(cid) + entries.append({"id": cid, "name": name, "url": url, + "active": bool(item.get("active"))}) + + try: + save_config(entries) + except OSError as err: + self.fail(500, "Could not write cameras.json (%s)" % err) + return + + apply_config(entries) + self.serve_json({"ok": True, "cameras": config_entries()}) + + def do_HEAD(self): + if urlparse(self.path).path in ("/", "/index.html"): + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", "0") + self.end_headers() + else: + self.fail(404, "Nothing is served here") + + # ---- handlers ---- + + def serve_page(self): + self.serve_file(PAGE, "text/html; charset=utf-8") + + def serve_file(self, path, ctype): + try: + with open(path, "rb") as fh: + body = fh.read() + except OSError: + self.fail(500, "%s is missing. Keep it next to server.py." + % os.path.basename(path)) + return + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def serve_stored(self, path, force_download=False): + """Serve a recording or photo, refusing anything outside the data folder.""" + root = RECORD_DIR if path.startswith("/recordings/") else PHOTO_DIR + rel = path.split("/", 2)[2] if path.count("/") >= 2 else "" + target = os.path.normpath(os.path.join(root, unquote(rel))) + if not target.startswith(os.path.normpath(root) + os.sep): + self.fail(403, "That is not inside the recordings folder.") + return + if not os.path.isfile(target): + self.fail(404, "No such file.") + return + ctype = ("video/x-matroska" if target.endswith(".mkv") + else "image/png" if target.endswith(".png") + else "image/jpeg" if target.endswith((".jpg", ".jpeg")) + else "application/octet-stream") + try: + size = os.path.getsize(target) + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(size)) + # Served inline so a photo can simply be looked at. Saving is driven + # by the link instead, or by asking for it explicitly; forcing every + # request to be a download would stop thumbnails rendering at all. + if force_download: + self.send_header("Content-Disposition", + 'attachment; filename="%s"' % os.path.basename(target)) + self.end_headers() + with open(target, "rb") as fh: + while True: + chunk = fh.read(65536) + if not chunk: + break + self.wfile.write(chunk) + except ConnectionError: + pass + except OSError as err: + self.fail(500, "Could not read that file (%s)" % err) + + def serve_json(self, obj): + body = json.dumps(obj).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def serve_stream(self, cam): + if not cam.available: + self.fail(503, cam.reason or "This camera is unavailable.") + return + if cam.kind == "mjpeg": + self.relay_mjpeg(cam) + else: + self.relay_frames(cam) + + def relay_mjpeg(self, cam): + """Straight passthrough: the camera's own JPEG bytes, untouched.""" + try: + upstream = cam.open_upstream("stream") + except (URLError, HTTPError, socket.timeout) as err: + self.fail(502, "Cannot reach %s at %s (%s)" % (cam.name, safe_host(cam.url), err)) + return + + ctype = upstream.headers.get( + "Content-Type", "multipart/x-mixed-replace; boundary=boundarydonotcross") + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Cache-Control", "no-store, no-cache") + self.send_header("Pragma", "no-cache") + self.send_header("Connection", "close") + self.end_headers() + self.close_connection = True + + try: + while True: + chunk = upstream.read(8192) + if not chunk: + break + self.wfile.write(chunk) + except ConnectionError: + # The viewer closed the tab, switched camera, or reloaded. Windows + # reports this as ConnectionAbortedError and Unix as BrokenPipeError, + # so catch the shared base class rather than one platform's spelling. + pass + finally: + upstream.close() + + def relay_frames(self, cam): + """Re-publish decoded frames as multipart JPEG.""" + q = cam.subscribe() + self.send_response(200) + self.send_header("Content-Type", + "multipart/x-mixed-replace; boundary=%s" % BOUNDARY) + self.send_header("Cache-Control", "no-store, no-cache") + self.send_header("Pragma", "no-cache") + self.send_header("Connection", "close") + self.end_headers() + self.close_connection = True + + head = ("--%s\r\nContent-Type: image/jpeg\r\nContent-Length: " % BOUNDARY).encode() + try: + while True: + try: + frame = q.get(timeout=15) + except Empty: + break # nothing arriving; let the page reconnect + self.wfile.write(head + str(len(frame)).encode() + b"\r\n\r\n") + self.wfile.write(frame) + self.wfile.write(b"\r\n") + except ConnectionError: + pass # viewer went away; unsubscribing below frees ffmpeg + finally: + cam.unsubscribe(q) + + def serve_snapshot(self, cam): + if not cam.available: + self.fail(503, cam.reason or "This camera is unavailable.") + return + if cam.kind == "mjpeg": + try: + upstream = cam.open_upstream("snapshot") + body = upstream.read() + ctype = upstream.headers.get("Content-Type", "image/jpeg") + upstream.close() + except (URLError, HTTPError, socket.timeout) as err: + self.fail(502, "Cannot reach %s at %s (%s)" % (cam.name, safe_host(cam.url), err)) + return + else: + body = cam.snapshot() + ctype = "image/jpeg" + if not body: + self.fail(502, "No picture yet from %s. %s" + % (cam.name, cam.describe().get("reason") or "")) + return + + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + # ---- helpers ---- + + def fail(self, code, message): + body = message.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + try: + self.wfile.write(body) + except ConnectionError: + pass + + def log_message(self, fmt, *args): + try: + line = fmt % args + except (TypeError, ValueError): + line = fmt + if "/stream" in line: + return + sys.stderr.write(" %s\n" % line) + + +# -------------------------------------------------------------------------- +# startup +# -------------------------------------------------------------------------- + +def build_camera(index, name, url, args): + return make_camera("cam%d" % (index + 1), name, url, args) + + +def lan_address(): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.connect(("192.0.2.1", 1)) # never actually sends anything + return sock.getsockname()[0] + except OSError: + return "127.0.0.1" + finally: + sock.close() + + +def main(): + parser = argparse.ArgumentParser( + description="Serve a viewer/recorder page for MJPEG and RTSP cameras.") + parser.add_argument("--camera", action="append", default=[], metavar="NAME=URL", + help="Add a camera. Repeatable. RTSP URLs are decoded with ffmpeg.") + parser.add_argument("--source", help="Shorthand for a single MJPEG camera.") + parser.add_argument("--port", type=int, default=8000, help="Port to listen on") + parser.add_argument("--host", default="0.0.0.0", + help="Address to bind. Use 127.0.0.1 to keep it on this machine only.") + parser.add_argument("--rtsp-quality", type=int, default=3, metavar="1-31", + help="JPEG quality for RTSP cameras, 1 is best (default: %(default)s)") + parser.add_argument("--rtsp-fps", type=float, default=None, + help="Cap the frame rate of RTSP cameras to save CPU") + parser.add_argument("--rtsp-size", default=None, metavar="WxH", + help="Scale RTSP cameras down, e.g. 1280x720, to save CPU") + parser.add_argument("--verbose", action="store_true", + help="Show all ffmpeg output, including routine decoder chatter") + parser.add_argument("--lock-config", action="store_true", + help="Serve the cameras read-only; the config page cannot save") + parser.add_argument("--data-dir", default=None, + help="Where recordings, photos and cameras.json live " + "(default: ./data, or $CAMERA_STATION_DATA)") + parser.add_argument("--storage-limit", default=None, metavar="GB", + help="Total footage to keep before deleting the oldest " + "(default: 10)") + parser.add_argument("--segment-minutes", type=float, default=None, + help="Length of each recording file (default: 10)") + args = parser.parse_args() + + global LOCKED, DATA_DIR, CONFIG_FILE, RECORD_DIR, PHOTO_DIR + global STORAGE_LIMIT, SEGMENT_SECONDS + LOCKED = args.lock_config + + if args.data_dir: + DATA_DIR = os.path.abspath(args.data_dir) + CONFIG_FILE = os.path.join(DATA_DIR, "cameras.json") + RECORD_DIR = os.path.join(DATA_DIR, "recordings") + PHOTO_DIR = os.path.join(DATA_DIR, "photos") + if args.storage_limit: + try: + STORAGE_LIMIT = int(float(args.storage_limit) * 1024 ** 3) + except ValueError: + parser.error("--storage-limit takes a number of gigabytes") + if args.segment_minutes: + SEGMENT_SECONDS = max(10, int(args.segment_minutes * 60)) + ensure_dirs() + OPTS.rtsp_quality = args.rtsp_quality + OPTS.rtsp_fps = args.rtsp_fps + OPTS.rtsp_size = args.rtsp_size + OPTS.verbose = args.verbose + + specs = [] + if args.source: + specs.append(("Camera 1", args.source)) + for entry in args.camera: + name, sep, url = entry.partition("=") + if not sep: + name, url = "Camera %d" % (len(specs) + 1), entry + specs.append((name.strip(), url.strip())) + if specs: + # Cameras named on the command line win for this run and are not saved. + entries = [{"id": "cam%d" % (i + 1), "name": n, "url": u} + for i, (n, u) in enumerate(specs)] + source = "command line" + else: + entries = load_config() + source = "cameras.json" + if entries is None: + entries = [{"id": "cam%d" % (i + 1), "name": n, "url": u} + for i, (n, u) in enumerate(DEFAULT_CAMERAS)] + source = "defaults" + + apply_config(entries) + threading.Thread(target=supervise, daemon=True).start() + + httpd = Station((args.host, args.port), Handler) + + shown = "localhost" if args.host in ("0.0.0.0", "127.0.0.1") else args.host + print("\n Camera Station") + for cam in CAMERAS: + note = "" if cam.available else " (%s)" % cam.reason + mark = "REC " if getattr(cam, "active", False) else " " + print(" %s %-6s %-6s %s%s" % (mark, cam.id, cam.kind, safe_host(cam.url), note)) + print(" from %s" % source) + print(" data %s" % DATA_DIR) + print(" keeps %.0f GB of footage, oldest deleted first" + % (STORAGE_LIMIT / 1024 ** 3)) + print(" open http://%s:%d" % (shown, args.port)) + print(" set up http://%s:%d/config%s" + % (shown, args.port, " (locked)" if LOCKED else "")) + print(" files http://%s:%d/files" % (shown, args.port)) + if args.host == "0.0.0.0": + print(" on LAN http://%s:%d" % (lan_address(), args.port)) + print(" Ctrl+C to stop\n") + + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\n Stopping.") + for cam in CAMERAS: + if getattr(cam, "recorder", None): + cam.recorder.stop() # closes the segment being written + if isinstance(cam, RtspCamera): + cam._stop_if_idle() + httpd.server_close() + print(" Stopped.\n") + + +if __name__ == "__main__": + main()