From 2015c4911f2106d0084a62cdd155f7fff44f404d Mon Sep 17 00:00:00 2001 From: jpmvaz Date: Sun, 13 Sep 2026 20:24:20 +0100 Subject: [PATCH] v_2 --- .dockerignore | 5 + Dockerfile | 33 + README.md | 410 ++++++++++++ config.html | 523 +++++++++++++++ docker-compose.yml | 28 + files.zip | Bin 0 -> 52220 bytes index.html | 1376 ++++++++++++++++++++++++++++++++++++++ library.html | 443 +++++++++++++ server.py | 1585 ++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 4403 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 config.html create mode 100644 docker-compose.yml create mode 100644 files.zip create mode 100644 index.html create mode 100644 library.html create mode 100644 server.py 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 0000000000000000000000000000000000000000..eef7865e2257523a5e5734c99ef51c6ded135d4a GIT binary patch literal 52220 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 MH``z0@4mhL2gGaW + + + + + +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()