#!/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()