v_2
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
data/
|
||||
cameras.json
|
||||
__pycache__/
|
||||
*.pyc
|
||||
README.md
|
||||
+33
@@ -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"]
|
||||
@@ -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/<camera>/ 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 | <kbd>1</kbd> <kbd>2</kbd> |
|
||||
| Start / stop recording on the server | Record button | <kbd>R</kbd> |
|
||||
| Take a photo, saved on the server | Take photo button | <kbd>Space</kbd> |
|
||||
| Zoom in / out | Scroll, pinch, or the +/− buttons | <kbd>+</kbd> <kbd>−</kbd> |
|
||||
| Pan while zoomed | Drag the picture | arrow keys |
|
||||
| Back to the whole frame | Fit button, or double-click | <kbd>0</kbd> |
|
||||
| Switch camera | Tabs above the picture | <kbd>1</kbd> <kbd>2</kbd> |
|
||||
|
||||
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
|
||||
```
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Camera Station · Set up</title>
|
||||
<style>
|
||||
:root{
|
||||
--chassis:#0e1116;
|
||||
--panel:#161b22;
|
||||
--panel-hi:#1e252e;
|
||||
--edge:#2a323d;
|
||||
--ink:#e6e4df;
|
||||
--ink-dim:#7d8794;
|
||||
--phosphor:#ffb347;
|
||||
--tally:#ff3b30;
|
||||
--good:#3ddc84;
|
||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;
|
||||
--sans: "Helvetica Neue", "Inter", "Segoe UI", system-ui, sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{
|
||||
margin:0;padding:28px 22px 60px;background:var(--chassis);color:var(--ink);
|
||||
font-family:var(--mono);font-size:13px;line-height:1.5;
|
||||
display:flex;flex-direction:column;gap:20px;align-items:center;
|
||||
}
|
||||
.wrap{width:100%;max-width:900px;display:flex;flex-direction:column;gap:18px}
|
||||
|
||||
header{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap}
|
||||
h1{font-size:13px;letter-spacing:.34em;text-transform:uppercase;margin:0;font-weight:600}
|
||||
.sub{color:var(--ink-dim);font-size:11px;letter-spacing:.1em}
|
||||
a.back{
|
||||
color:var(--phosphor);text-decoration:none;font-size:11px;
|
||||
letter-spacing:.18em;text-transform:uppercase;border-bottom:1px solid #4a3a1c;padding-bottom:2px;
|
||||
}
|
||||
a.back:hover{border-bottom-color:var(--phosphor)}
|
||||
|
||||
.note{
|
||||
background:var(--panel);border:1px solid var(--edge);border-left:2px solid var(--phosphor);
|
||||
padding:12px 15px;color:var(--ink-dim);font-size:11.5px;line-height:1.65;border-radius:2px;
|
||||
}
|
||||
.note b{color:var(--ink);font-weight:600}
|
||||
.note.warn{border-left-color:var(--tally)}
|
||||
|
||||
.rows{display:flex;flex-direction:column;gap:10px}
|
||||
.row{
|
||||
background:var(--panel);border:1px solid var(--edge);border-radius:2px;
|
||||
padding:13px 14px;display:grid;gap:10px;
|
||||
grid-template-columns:minmax(130px,190px) 1fr auto;align-items:start;
|
||||
}
|
||||
.row[data-kind="rtsp"]{border-left:2px solid #3a4a63}
|
||||
.row[data-kind="mjpeg"]{border-left:2px solid #4a3a1c}
|
||||
|
||||
label{display:flex;flex-direction:column;gap:5px;min-width:0}
|
||||
label span{font-size:9.5px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-dim)}
|
||||
input{
|
||||
font-family:var(--mono);font-size:12.5px;color:var(--ink);
|
||||
background:var(--chassis);border:1px solid var(--edge);border-radius:2px;
|
||||
padding:9px 10px;width:100%;min-width:0;
|
||||
}
|
||||
input:focus{outline:none;border-color:#4a5768;background:#0b0e12}
|
||||
input::placeholder{color:#4d5561}
|
||||
|
||||
.rowbtns{display:flex;gap:5px;align-items:center;padding-top:19px}
|
||||
button{
|
||||
font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
|
||||
color:var(--ink);background:var(--panel-hi);border:1px solid var(--edge);
|
||||
border-radius:2px;padding:9px 12px;cursor:pointer;white-space:nowrap;
|
||||
}
|
||||
button:hover:not(:disabled){background:#28313d}
|
||||
button:disabled{opacity:.35;cursor:default}
|
||||
button.icon{padding:9px 10px;min-width:34px;text-align:center}
|
||||
button.danger:hover:not(:disabled){border-color:var(--tally);color:var(--tally)}
|
||||
button.primary{background:var(--phosphor);color:#171310;border-color:var(--phosphor);font-weight:700}
|
||||
button.primary:hover:not(:disabled){background:#ffc266}
|
||||
|
||||
button.toggle{min-width:118px;justify-content:center}
|
||||
button.toggle[data-on="yes"]{
|
||||
background:#3a1512;border-color:var(--tally);color:#ff9d95;font-weight:700;
|
||||
}
|
||||
button.toggle[data-on="yes"]::before{
|
||||
content:"";display:inline-block;width:7px;height:7px;border-radius:50%;
|
||||
background:var(--tally);margin-right:8px;vertical-align:middle;
|
||||
animation:pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
|
||||
|
||||
.storage{
|
||||
background:var(--panel);border:1px solid var(--edge);border-radius:2px;padding:15px 16px;
|
||||
display:flex;flex-direction:column;gap:12px;
|
||||
}
|
||||
.storage h2{margin:0;font-size:11px;letter-spacing:.24em;text-transform:uppercase;font-weight:600}
|
||||
.bar{height:8px;background:var(--chassis);border:1px solid var(--edge);border-radius:2px;overflow:hidden}
|
||||
.bar i{display:block;height:100%;background:var(--phosphor);transition:width .4s ease}
|
||||
.bar[data-full="yes"] i{background:var(--tally)}
|
||||
.usage{font-size:11px;color:var(--ink-dim);letter-spacing:.06em}
|
||||
.usage b{color:var(--ink);font-weight:600}
|
||||
.files{display:flex;flex-direction:column;gap:4px;max-height:230px;overflow:auto}
|
||||
.files a{
|
||||
display:flex;justify-content:space-between;gap:12px;text-decoration:none;
|
||||
color:var(--ink-dim);font-size:11px;padding:6px 8px;border:1px solid transparent;border-radius:2px;
|
||||
}
|
||||
.files a:hover{color:var(--ink);border-color:var(--edge);background:var(--chassis)}
|
||||
.files a span:last-child{color:var(--ink-dim);white-space:nowrap}
|
||||
|
||||
.result{
|
||||
grid-column:1 / -1;font-size:11px;letter-spacing:.05em;padding:8px 10px;border-radius:2px;
|
||||
background:var(--chassis);border:1px solid var(--edge);color:var(--ink-dim);
|
||||
word-break:break-word;
|
||||
}
|
||||
.result[data-state="ok"]{color:var(--good);border-color:#1d4530}
|
||||
.result[data-state="bad"]{color:#ff8a80;border-color:#4d2320}
|
||||
.result[data-state="busy"]{color:var(--phosphor)}
|
||||
.result[hidden]{display:none}
|
||||
|
||||
.actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
||||
.actions .spacer{flex:1}
|
||||
.status{font-size:11px;letter-spacing:.08em;color:var(--ink-dim)}
|
||||
.status[data-state="ok"]{color:var(--good)}
|
||||
.status[data-state="bad"]{color:#ff8a80}
|
||||
|
||||
.empty{
|
||||
border:1px dashed var(--edge);border-radius:2px;padding:26px;text-align:center;
|
||||
color:var(--ink-dim);font-size:11.5px;
|
||||
}
|
||||
@media (max-width:720px){
|
||||
.row{grid-template-columns:1fr}
|
||||
.rowbtns{padding-top:0;flex-wrap:wrap}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<div>
|
||||
<h1>Camera Station · Set up</h1>
|
||||
<div class="sub" id="sub">Loading…</div>
|
||||
</div>
|
||||
<nav style="display:flex;gap:10px">
|
||||
<a class="back" href="/files">Files</a>
|
||||
<a class="back" href="/">← Viewer</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="note" id="hint">
|
||||
Add as many cameras as you like. An address starting <b>http://</b> is treated as an
|
||||
MJPEG feed and relayed untouched; one starting <b>rtsp://</b> is decoded by ffmpeg on
|
||||
this machine. <b>Test</b> pulls a single frame so you can check a camera before saving.
|
||||
</div>
|
||||
|
||||
<div class="note" id="ffmpegWarn" hidden>
|
||||
ffmpeg was not found, so RTSP cameras cannot be decoded. MJPEG cameras still work.
|
||||
</div>
|
||||
|
||||
<div class="note warn" id="lockWarn" hidden>
|
||||
This server was started with <b>--lock-config</b>, so cameras can be viewed here but not changed.
|
||||
</div>
|
||||
|
||||
<div class="rows" id="rows"></div>
|
||||
<div class="empty" id="empty" hidden>No cameras yet. Add one below.</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="add">+ Add camera</button>
|
||||
<button id="testAll">Test all</button>
|
||||
<span class="spacer"></span>
|
||||
<span class="status" id="status"></span>
|
||||
<button id="revert">Revert</button>
|
||||
<button class="primary" id="save">Save changes</button>
|
||||
</div>
|
||||
|
||||
<div class="storage">
|
||||
<h2>Stored footage <a href="/files" style="color:var(--phosphor);text-decoration:none;font-size:10px;letter-spacing:.18em">browse all →</a></h2>
|
||||
<div class="bar" id="bar"><i style="width:0"></i></div>
|
||||
<div class="usage" id="usage">Reading…</div>
|
||||
<div class="files" id="files"></div>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
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 <b>cameras.json</b> next to server.py.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var rowsEl = document.getElementById("rows");
|
||||
var emptyEl = document.getElementById("empty");
|
||||
var statusEl = document.getElementById("status");
|
||||
var subEl = document.getElementById("sub");
|
||||
var saveBtn = document.getElementById("save");
|
||||
var revertBtn = document.getElementById("revert");
|
||||
var addBtn = document.getElementById("add");
|
||||
var testAllBtn = document.getElementById("testAll");
|
||||
|
||||
var cameras = []; // what is on screen
|
||||
var saved = []; // what the server last confirmed
|
||||
var locked = false;
|
||||
var ffmpegOk = true;
|
||||
|
||||
function kindOf(url) {
|
||||
return /^rtsps?:\/\//i.test(url || "") ? "rtsp" : "mjpeg";
|
||||
}
|
||||
|
||||
function setStatus(text, state) {
|
||||
statusEl.textContent = text || "";
|
||||
if (state) statusEl.dataset.state = state; else delete statusEl.dataset.state;
|
||||
}
|
||||
|
||||
function dirty() {
|
||||
return JSON.stringify(cameras.map(function (c) { return [c.name, c.url]; })) !==
|
||||
JSON.stringify(saved.map(function (c) { return [c.name, c.url]; }));
|
||||
}
|
||||
|
||||
function refreshButtons() {
|
||||
var changed = dirty();
|
||||
saveBtn.disabled = locked || !changed;
|
||||
revertBtn.disabled = !changed;
|
||||
addBtn.disabled = locked;
|
||||
emptyEl.hidden = cameras.length > 0;
|
||||
}
|
||||
|
||||
function render() {
|
||||
rowsEl.textContent = "";
|
||||
cameras.forEach(function (cam, index) {
|
||||
var row = document.createElement("div");
|
||||
row.className = "row";
|
||||
row.dataset.kind = kindOf(cam.url);
|
||||
|
||||
var nameLabel = document.createElement("label");
|
||||
var nameSpan = document.createElement("span");
|
||||
nameSpan.textContent = "Name";
|
||||
var nameInput = document.createElement("input");
|
||||
nameInput.value = cam.name;
|
||||
nameInput.placeholder = "Camera " + (index + 1);
|
||||
nameInput.disabled = locked;
|
||||
nameInput.addEventListener("input", function () {
|
||||
cam.name = nameInput.value;
|
||||
refreshButtons();
|
||||
});
|
||||
nameLabel.appendChild(nameSpan);
|
||||
nameLabel.appendChild(nameInput);
|
||||
|
||||
var urlLabel = document.createElement("label");
|
||||
var urlSpan = document.createElement("span");
|
||||
urlSpan.textContent = "Address";
|
||||
var urlInput = document.createElement("input");
|
||||
urlInput.value = cam.url;
|
||||
urlInput.spellcheck = false;
|
||||
urlInput.placeholder = "http://192.168.1.50:8080 or rtsp://user:pass@192.168.1.60:554/stream";
|
||||
urlInput.disabled = locked;
|
||||
urlInput.addEventListener("input", function () {
|
||||
cam.url = urlInput.value;
|
||||
row.dataset.kind = kindOf(cam.url);
|
||||
refreshButtons();
|
||||
});
|
||||
urlLabel.appendChild(urlSpan);
|
||||
urlLabel.appendChild(urlInput);
|
||||
|
||||
var btns = document.createElement("div");
|
||||
btns.className = "rowbtns";
|
||||
|
||||
var rec = document.createElement("button");
|
||||
rec.className = "toggle";
|
||||
rec.dataset.on = cam.active ? "yes" : "no";
|
||||
rec.textContent = cam.active ? "Recording" : "Not recording";
|
||||
rec.title = cam.active
|
||||
? "This camera is being recorded to disk continuously. Click to stop."
|
||||
: "Click to record this camera continuously, even when nobody is watching.";
|
||||
rec.disabled = locked || !cam.id;
|
||||
rec.addEventListener("click", function () { toggleActive(cam, rec); });
|
||||
|
||||
var testBtn = document.createElement("button");
|
||||
testBtn.textContent = "Test";
|
||||
testBtn.addEventListener("click", function () { testOne(cam, row, testBtn); });
|
||||
|
||||
var up = document.createElement("button");
|
||||
up.className = "icon"; up.textContent = "\u2191";
|
||||
up.title = "Move up";
|
||||
up.disabled = locked || index === 0;
|
||||
up.addEventListener("click", function () { move(index, -1); });
|
||||
|
||||
var down = document.createElement("button");
|
||||
down.className = "icon"; down.textContent = "\u2193";
|
||||
down.title = "Move down";
|
||||
down.disabled = locked || index === cameras.length - 1;
|
||||
down.addEventListener("click", function () { move(index, 1); });
|
||||
|
||||
var del = document.createElement("button");
|
||||
del.className = "icon danger"; del.textContent = "\u00d7";
|
||||
del.title = "Remove this camera";
|
||||
del.disabled = locked;
|
||||
del.addEventListener("click", function () {
|
||||
cameras.splice(index, 1);
|
||||
render();
|
||||
setStatus("Removed. Save to apply.", null);
|
||||
});
|
||||
|
||||
btns.appendChild(rec); btns.appendChild(testBtn); btns.appendChild(up);
|
||||
btns.appendChild(down); btns.appendChild(del);
|
||||
|
||||
var result = document.createElement("div");
|
||||
result.className = "result";
|
||||
result.hidden = true;
|
||||
|
||||
row.appendChild(nameLabel);
|
||||
row.appendChild(urlLabel);
|
||||
row.appendChild(btns);
|
||||
row.appendChild(result);
|
||||
rowsEl.appendChild(row);
|
||||
});
|
||||
refreshButtons();
|
||||
}
|
||||
|
||||
function toggleActive(cam, button) {
|
||||
if (!cam.id) {
|
||||
setStatus("Save this camera first, then it can be recorded.", "bad");
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
fetch("/api/active", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: cam.id, active: !cam.active })
|
||||
}).then(function (r) {
|
||||
return r.text().then(function (body) {
|
||||
if (!r.ok) throw new Error(body || "The server refused that.");
|
||||
return JSON.parse(body);
|
||||
});
|
||||
}).then(function (state) {
|
||||
cam.active = state.active;
|
||||
var match = saved.filter(function (c) { return c.id === cam.id; })[0];
|
||||
if (match) match.active = state.active; // this is saved immediately, so it is not a pending edit
|
||||
button.dataset.on = cam.active ? "yes" : "no";
|
||||
button.textContent = cam.active ? "Recording" : "Not recording";
|
||||
setStatus(cam.active
|
||||
? cam.name + " is now recorded continuously."
|
||||
: cam.name + " is no longer being recorded.", "ok");
|
||||
loadStorage();
|
||||
}).catch(function (err) {
|
||||
setStatus(String(err.message || err), "bad");
|
||||
}).finally(function () {
|
||||
button.disabled = locked;
|
||||
refreshButtons();
|
||||
});
|
||||
}
|
||||
|
||||
function move(index, delta) {
|
||||
var target = index + delta;
|
||||
if (target < 0 || target >= cameras.length) return;
|
||||
var moved = cameras.splice(index, 1)[0];
|
||||
cameras.splice(target, 0, moved);
|
||||
render();
|
||||
}
|
||||
|
||||
function showResult(row, text, state) {
|
||||
var box = row.querySelector(".result");
|
||||
box.hidden = false;
|
||||
box.textContent = text;
|
||||
if (state) box.dataset.state = state; else delete box.dataset.state;
|
||||
}
|
||||
|
||||
function testOne(cam, row, button) {
|
||||
showResult(row, "Pulling a frame\u2026", "busy");
|
||||
if (button) button.disabled = true;
|
||||
return fetch("/api/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: cam.url })
|
||||
}).then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (res.ok) {
|
||||
var size = res.width ? res.width + "\u00d7" + res.height : "picture received";
|
||||
showResult(row, "Working \u00b7 " + (res.kind || "") + " \u00b7 " + size, "ok");
|
||||
} else {
|
||||
showResult(row, res.message || "No picture came back.", "bad");
|
||||
}
|
||||
})
|
||||
.catch(function () { showResult(row, "Could not reach this server.", "bad"); })
|
||||
.finally(function () { if (button) button.disabled = false; });
|
||||
}
|
||||
|
||||
function testAll() {
|
||||
var rows = rowsEl.querySelectorAll(".row");
|
||||
testAllBtn.disabled = true;
|
||||
// One at a time: several RTSP probes at once would fight for the CPU and
|
||||
// make each other look slow.
|
||||
var chain = Promise.resolve();
|
||||
cameras.forEach(function (cam, i) {
|
||||
chain = chain.then(function () { return testOne(cam, rows[i], null); });
|
||||
});
|
||||
chain.finally(function () { testAllBtn.disabled = false; });
|
||||
}
|
||||
|
||||
function save() {
|
||||
setStatus("Saving\u2026", null);
|
||||
saveBtn.disabled = true;
|
||||
fetch("/api/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cameras: cameras })
|
||||
}).then(function (r) {
|
||||
return r.text().then(function (body) {
|
||||
if (!r.ok) throw new Error(body || ("Save failed (" + r.status + ")"));
|
||||
return JSON.parse(body);
|
||||
});
|
||||
}).then(function (res) {
|
||||
cameras = res.cameras.map(function (c) { return Object.assign({}, c); });
|
||||
saved = res.cameras.map(function (c) { return Object.assign({}, c); });
|
||||
render();
|
||||
setStatus("Saved. The viewer updates within a few seconds.", "ok");
|
||||
}).catch(function (err) {
|
||||
setStatus(String(err.message || err), "bad");
|
||||
refreshButtons();
|
||||
});
|
||||
}
|
||||
|
||||
addBtn.addEventListener("click", function () {
|
||||
cameras.push({ id: "", name: "", url: "" });
|
||||
render();
|
||||
var inputs = rowsEl.querySelectorAll(".row:last-child input");
|
||||
if (inputs.length) inputs[0].focus();
|
||||
});
|
||||
|
||||
revertBtn.addEventListener("click", function () {
|
||||
cameras = saved.map(function (c) { return Object.assign({}, c); });
|
||||
render();
|
||||
setStatus("Reverted to the saved cameras.", null);
|
||||
});
|
||||
|
||||
saveBtn.addEventListener("click", save);
|
||||
testAllBtn.addEventListener("click", testAll);
|
||||
|
||||
window.addEventListener("beforeunload", function (e) {
|
||||
if (dirty()) { e.preventDefault(); e.returnValue = ""; }
|
||||
});
|
||||
|
||||
function human(n) {
|
||||
if (!n) return "0 B";
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1048576) return (n / 1024).toFixed(0) + " KB";
|
||||
if (n < 1073741824) return (n / 1048576).toFixed(1) + " MB";
|
||||
return (n / 1073741824).toFixed(2) + " GB";
|
||||
}
|
||||
|
||||
function when(ts) {
|
||||
if (!ts) return "";
|
||||
var d = new Date(ts * 1000);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function loadStorage() {
|
||||
fetch("/api/storage")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (s) {
|
||||
var pct = s.limit ? Math.min(100, (s.used / s.limit) * 100) : 0;
|
||||
var bar = document.getElementById("bar");
|
||||
bar.firstElementChild.style.width = pct.toFixed(1) + "%";
|
||||
bar.dataset.full = pct > 92 ? "yes" : "no";
|
||||
var parts = [human(s.used) + " of " + human(s.limit),
|
||||
s.segments + (s.segments === 1 ? " file" : " files")];
|
||||
if (s.photoCount) parts.push(s.photoCount + " photos (" + human(s.photoBytes) + ")");
|
||||
document.getElementById("usage").innerHTML =
|
||||
"<b>" + parts[0] + "</b> \u00b7 " + parts.slice(1).join(" \u00b7 ");
|
||||
|
||||
var list = document.getElementById("files");
|
||||
list.textContent = "";
|
||||
(s.recent || []).forEach(function (f) {
|
||||
var a = document.createElement("a");
|
||||
a.href = "/recordings/" + encodeURIComponent(f.folder) + "/" + encodeURIComponent(f.name);
|
||||
var left = document.createElement("span");
|
||||
left.textContent = f.folder + " \u00b7 " + f.name;
|
||||
var right = document.createElement("span");
|
||||
right.textContent = human(f.size) + " " + when(f.mtime);
|
||||
a.appendChild(left); a.appendChild(right);
|
||||
list.appendChild(a);
|
||||
});
|
||||
if (!(s.recent || []).length) {
|
||||
var none = document.createElement("a");
|
||||
none.textContent = "Nothing recorded yet.";
|
||||
none.removeAttribute("href");
|
||||
list.appendChild(none);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
document.getElementById("usage").textContent = "Could not read storage.";
|
||||
});
|
||||
}
|
||||
|
||||
loadStorage();
|
||||
setInterval(loadStorage, 10000);
|
||||
|
||||
fetch("/api/config")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
locked = !!data.locked;
|
||||
ffmpegOk = data.ffmpeg !== false;
|
||||
cameras = (data.cameras || []).map(function (c) { return Object.assign({}, c); });
|
||||
saved = (data.cameras || []).map(function (c) { return Object.assign({}, c); });
|
||||
document.getElementById("lockWarn").hidden = !locked;
|
||||
document.getElementById("ffmpegWarn").hidden = ffmpegOk;
|
||||
subEl.textContent = cameras.length + (cameras.length === 1 ? " camera" : " cameras");
|
||||
render();
|
||||
})
|
||||
.catch(function () {
|
||||
subEl.textContent = "Could not load the current cameras.";
|
||||
setStatus("Is server.py still running?", "bad");
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
+1376
File diff suppressed because it is too large
Load Diff
+443
@@ -0,0 +1,443 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Camera Station · Files</title>
|
||||
<style>
|
||||
:root{
|
||||
--chassis:#0e1116;
|
||||
--panel:#161b22;
|
||||
--panel-hi:#1e252e;
|
||||
--edge:#2a323d;
|
||||
--ink:#e6e4df;
|
||||
--ink-dim:#7d8794;
|
||||
--phosphor:#ffb347;
|
||||
--tally:#ff3b30;
|
||||
--good:#3ddc84;
|
||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "DejaVu Sans Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{
|
||||
margin:0;padding:28px 22px 70px;background:var(--chassis);color:var(--ink);
|
||||
font-family:var(--mono);font-size:13px;line-height:1.5;
|
||||
display:flex;flex-direction:column;align-items:center;
|
||||
}
|
||||
.wrap{width:100%;max-width:1000px;display:flex;flex-direction:column;gap:16px}
|
||||
|
||||
header{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap}
|
||||
h1{font-size:13px;letter-spacing:.34em;text-transform:uppercase;margin:0;font-weight:600}
|
||||
.sub{color:var(--ink-dim);font-size:11px;letter-spacing:.1em}
|
||||
nav.links{display:flex;gap:8px}
|
||||
nav.links a{
|
||||
color:var(--ink-dim);text-decoration:none;font-size:10px;letter-spacing:.18em;
|
||||
text-transform:uppercase;border:1px solid var(--edge);border-radius:2px;padding:7px 11px;
|
||||
}
|
||||
nav.links a:hover{color:var(--ink);border-color:#3c4654}
|
||||
|
||||
.tabs{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.tabs button{
|
||||
font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;
|
||||
color:var(--ink-dim);background:var(--panel);border:1px solid var(--edge);
|
||||
border-radius:2px;padding:10px 16px;cursor:pointer;
|
||||
}
|
||||
.tabs button[aria-pressed="true"]{background:var(--panel-hi);border-color:#3c4654;color:var(--ink)}
|
||||
|
||||
.filters{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
||||
select{
|
||||
font-family:var(--mono);font-size:12px;color:var(--ink);background:var(--chassis);
|
||||
border:1px solid var(--edge);border-radius:2px;padding:8px 10px;
|
||||
}
|
||||
select:focus{outline:none;border-color:#4a5768}
|
||||
.filters .spacer{flex:1}
|
||||
|
||||
button.act{
|
||||
font-family:var(--mono);font-size:10.5px;letter-spacing:.14em;text-transform:uppercase;
|
||||
color:var(--ink);background:var(--panel-hi);border:1px solid var(--edge);
|
||||
border-radius:2px;padding:9px 13px;cursor:pointer;white-space:nowrap;
|
||||
}
|
||||
button.act:hover:not(:disabled){background:#28313d}
|
||||
button.act:disabled{opacity:.35;cursor:default}
|
||||
button.act.danger:hover:not(:disabled){border-color:var(--tally);color:var(--tally)}
|
||||
|
||||
.storage{background:var(--panel);border:1px solid var(--edge);border-radius:2px;padding:13px 15px}
|
||||
.bar{height:8px;background:var(--chassis);border:1px solid var(--edge);border-radius:2px;overflow:hidden;margin-bottom:9px}
|
||||
.bar i{display:block;height:100%;background:var(--phosphor);transition:width .4s ease}
|
||||
.bar[data-full="yes"] i{background:var(--tally)}
|
||||
.usage{font-size:11px;color:var(--ink-dim)}
|
||||
.usage b{color:var(--ink);font-weight:600}
|
||||
|
||||
/* recordings list */
|
||||
.list{display:flex;flex-direction:column;gap:2px}
|
||||
.day{
|
||||
font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--ink-dim);
|
||||
padding:16px 2px 6px;border-bottom:1px solid var(--edge);margin-bottom:4px;
|
||||
}
|
||||
.item{
|
||||
display:grid;grid-template-columns:auto 1fr auto auto auto;gap:12px;align-items:center;
|
||||
background:var(--panel);border:1px solid var(--edge);border-radius:2px;padding:10px 12px;
|
||||
}
|
||||
.item:hover{border-color:#3c4654}
|
||||
.item .who{color:var(--phosphor);font-size:11px;letter-spacing:.1em}
|
||||
.item .when{color:var(--ink);font-size:12px}
|
||||
.item .size{color:var(--ink-dim);font-size:11px;white-space:nowrap}
|
||||
.item a.get{
|
||||
color:var(--ink-dim);text-decoration:none;font-size:10px;letter-spacing:.16em;
|
||||
text-transform:uppercase;border:1px solid var(--edge);border-radius:2px;padding:7px 11px;
|
||||
}
|
||||
.item a.get:hover{color:var(--ink);border-color:#3c4654;background:var(--panel-hi)}
|
||||
.item.busy{border-left:2px solid var(--tally)}
|
||||
.item .rec{color:var(--tally);font-size:9px;letter-spacing:.16em;text-transform:uppercase}
|
||||
|
||||
/* photo grid */
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(168px,1fr));gap:10px}
|
||||
.card{
|
||||
background:var(--panel);border:1px solid var(--edge);border-radius:2px;overflow:hidden;
|
||||
display:flex;flex-direction:column;
|
||||
}
|
||||
.card:hover{border-color:#3c4654}
|
||||
.card .frame{position:relative;background:#0a0d11;aspect-ratio:16/9;display:block}
|
||||
.card img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.card .meta{padding:8px 10px;display:flex;flex-direction:column;gap:3px}
|
||||
.card .who{color:var(--phosphor);font-size:10px;letter-spacing:.12em}
|
||||
.card .when{font-size:11px}
|
||||
.card .row{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:4px}
|
||||
.card .size{color:var(--ink-dim);font-size:10px}
|
||||
.card a.get{
|
||||
color:var(--ink-dim);text-decoration:none;font-size:9.5px;letter-spacing:.14em;
|
||||
text-transform:uppercase;border:1px solid var(--edge);border-radius:2px;padding:5px 8px;
|
||||
}
|
||||
.card a.get:hover{color:var(--ink);border-color:#3c4654}
|
||||
.card input{position:absolute;top:7px;left:7px;z-index:2;accent-color:var(--phosphor)}
|
||||
|
||||
input[type=checkbox]{width:15px;height:15px;accent-color:var(--phosphor);cursor:pointer}
|
||||
|
||||
.empty{
|
||||
border:1px dashed var(--edge);border-radius:2px;padding:34px;text-align:center;
|
||||
color:var(--ink-dim);font-size:11.5px;
|
||||
}
|
||||
.note{
|
||||
background:var(--panel);border:1px solid var(--edge);border-left:2px solid var(--phosphor);
|
||||
padding:11px 14px;color:var(--ink-dim);font-size:11px;line-height:1.6;border-radius:2px;
|
||||
}
|
||||
.note b{color:var(--ink);font-weight:600}
|
||||
.status{font-size:11px;color:var(--ink-dim)}
|
||||
.status[data-state="ok"]{color:var(--good)}
|
||||
.status[data-state="bad"]{color:#ff8a80}
|
||||
.more{display:flex;justify-content:center;padding:6px}
|
||||
@media (max-width:640px){
|
||||
.item{grid-template-columns:auto 1fr auto;row-gap:6px}
|
||||
.item .size{grid-column:2}
|
||||
.item a.get{grid-column:3}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<div>
|
||||
<h1>Camera Station · Files</h1>
|
||||
<div class="sub" id="sub">Loading…</div>
|
||||
</div>
|
||||
<nav class="links">
|
||||
<a href="/">Viewer</a>
|
||||
<a href="/config">Set up</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="tabs">
|
||||
<button id="tabRec" aria-pressed="true">Recordings</button>
|
||||
<button id="tabPho" aria-pressed="false">Photos</button>
|
||||
</div>
|
||||
|
||||
<div class="storage">
|
||||
<div class="bar" id="bar"><i style="width:0"></i></div>
|
||||
<div class="usage" id="usage">Reading…</div>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<select id="camera"><option value="">All cameras</option></select>
|
||||
<select id="day"><option value="">All dates</option></select>
|
||||
<span class="spacer"></span>
|
||||
<span class="status" id="status"></span>
|
||||
<button class="act" id="selectAll">Select all</button>
|
||||
<button class="act danger" id="del" disabled>Delete selected</button>
|
||||
</div>
|
||||
|
||||
<div class="note" id="playNote">
|
||||
Recordings are Matroska files holding the camera's own stream, so most browsers
|
||||
cannot play them in a tab. Download and open them in <b>VLC</b>, or convert one
|
||||
without re-encoding: <b>ffmpeg -i file.mkv -c copy file.mp4</b>
|
||||
</div>
|
||||
|
||||
<div id="body"></div>
|
||||
<div class="more"><button class="act" id="more" hidden>Load more</button></div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var kind = "recordings";
|
||||
var offset = 0;
|
||||
var loaded = [];
|
||||
var selected = {};
|
||||
var total = 0;
|
||||
|
||||
var el = {
|
||||
sub: document.getElementById("sub"),
|
||||
body: document.getElementById("body"),
|
||||
camera: document.getElementById("camera"),
|
||||
day: document.getElementById("day"),
|
||||
status: document.getElementById("status"),
|
||||
del: document.getElementById("del"),
|
||||
selectAll: document.getElementById("selectAll"),
|
||||
more: document.getElementById("more"),
|
||||
bar: document.getElementById("bar"),
|
||||
usage: document.getElementById("usage"),
|
||||
tabRec: document.getElementById("tabRec"),
|
||||
tabPho: document.getElementById("tabPho"),
|
||||
playNote: document.getElementById("playNote")
|
||||
};
|
||||
|
||||
function human(n) {
|
||||
if (!n) return "0 B";
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1048576) return (n / 1024).toFixed(0) + " KB";
|
||||
if (n < 1073741824) return (n / 1048576).toFixed(1) + " MB";
|
||||
return (n / 1073741824).toFixed(2) + " GB";
|
||||
}
|
||||
|
||||
function prettyDay(d) {
|
||||
if (!/^\d{8}$/.test(d)) return d;
|
||||
return d.slice(0, 4) + "-" + d.slice(4, 6) + "-" + d.slice(6, 8);
|
||||
}
|
||||
|
||||
function setStatus(text, state) {
|
||||
el.status.textContent = text || "";
|
||||
if (state) el.status.dataset.state = state; else delete el.status.dataset.state;
|
||||
}
|
||||
|
||||
function refreshDelete() {
|
||||
var n = Object.keys(selected).length;
|
||||
el.del.disabled = !n;
|
||||
el.del.textContent = n ? "Delete " + n + " selected" : "Delete selected";
|
||||
}
|
||||
|
||||
function keep(select, values, current, allLabel) {
|
||||
select.textContent = "";
|
||||
var any = document.createElement("option");
|
||||
any.value = ""; any.textContent = allLabel;
|
||||
select.appendChild(any);
|
||||
values.forEach(function (v) {
|
||||
var o = document.createElement("option");
|
||||
o.value = v;
|
||||
o.textContent = select === el.day ? prettyDay(v) : v;
|
||||
select.appendChild(o);
|
||||
});
|
||||
select.value = current || "";
|
||||
}
|
||||
|
||||
function load(reset) {
|
||||
if (reset) { offset = 0; loaded = []; selected = {}; refreshDelete(); }
|
||||
var url = "/api/files?kind=" + kind +
|
||||
"&camera=" + encodeURIComponent(el.camera.value) +
|
||||
"&day=" + encodeURIComponent(el.day.value) +
|
||||
"&offset=" + offset;
|
||||
return fetch(url)
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
total = data.total;
|
||||
loaded = loaded.concat(data.files);
|
||||
keep(el.camera, data.cameras, el.camera.value, "All cameras");
|
||||
keep(el.day, data.days, el.day.value, "All dates");
|
||||
|
||||
var pct = data.limit ? Math.min(100, (data.used / data.limit) * 100) : 0;
|
||||
el.bar.firstElementChild.style.width = pct.toFixed(1) + "%";
|
||||
el.bar.dataset.full = pct > 92 ? "yes" : "no";
|
||||
el.usage.innerHTML = "<b>" + human(data.used) + " of " + human(data.limit) +
|
||||
"</b> of footage stored \u00b7 oldest is deleted first once it is full";
|
||||
|
||||
el.sub.textContent = total + (total === 1 ? " file" : " files") +
|
||||
(data.bytes ? " \u00b7 " + human(data.bytes) : "");
|
||||
el.more.hidden = loaded.length >= total;
|
||||
el.more.textContent = "Load more (" + (total - loaded.length) + " left)";
|
||||
render();
|
||||
})
|
||||
.catch(function () { setStatus("Could not read the file list.", "bad"); });
|
||||
}
|
||||
|
||||
function render() {
|
||||
el.body.textContent = "";
|
||||
if (!loaded.length) {
|
||||
var none = document.createElement("div");
|
||||
none.className = "empty";
|
||||
none.textContent = kind === "photos"
|
||||
? "No photos yet. Press Take photo in the viewer."
|
||||
: "No recordings yet. Turn a camera on from the set-up page.";
|
||||
el.body.appendChild(none);
|
||||
return;
|
||||
}
|
||||
if (kind === "photos") renderPhotos(); else renderRecordings();
|
||||
refreshDelete();
|
||||
}
|
||||
|
||||
function toggle(name, on) {
|
||||
if (on) selected[name] = true; else delete selected[name];
|
||||
refreshDelete();
|
||||
}
|
||||
|
||||
function renderRecordings() {
|
||||
var list = document.createElement("div");
|
||||
list.className = "list";
|
||||
var currentDay = null;
|
||||
loaded.forEach(function (f) {
|
||||
if (f.day !== currentDay) {
|
||||
currentDay = f.day;
|
||||
var head = document.createElement("div");
|
||||
head.className = "day";
|
||||
head.textContent = prettyDay(f.day);
|
||||
list.appendChild(head);
|
||||
}
|
||||
var rel = f.camera + "/" + f.name;
|
||||
var row = document.createElement("div");
|
||||
row.className = "item" + (f.live ? " busy" : "");
|
||||
|
||||
var box = document.createElement("input");
|
||||
box.type = "checkbox";
|
||||
box.checked = !!selected[rel];
|
||||
box.addEventListener("change", function () { toggle(rel, box.checked); });
|
||||
|
||||
var who = document.createElement("span"); who.className = "who"; who.textContent = f.camera;
|
||||
var when = document.createElement("span"); when.className = "when"; when.textContent = f.when;
|
||||
var size = document.createElement("span"); size.className = "size";
|
||||
size.textContent = human(f.size);
|
||||
if (f.live) {
|
||||
var live = document.createElement("span");
|
||||
live.className = "rec";
|
||||
live.textContent = "recording now";
|
||||
size.textContent = human(f.size) + " ";
|
||||
size.appendChild(live);
|
||||
}
|
||||
|
||||
var get = document.createElement("a");
|
||||
get.className = "get"; get.href = f.url + "?download=1";
|
||||
get.setAttribute("download", f.name);
|
||||
get.textContent = f.live ? "Download part" : "Download";
|
||||
get.title = f.live
|
||||
? "This file is still being written. You will get it as far as it has got."
|
||||
: "Download " + f.name;
|
||||
|
||||
row.appendChild(box); row.appendChild(when); row.appendChild(who);
|
||||
row.appendChild(size); row.appendChild(get);
|
||||
list.appendChild(row);
|
||||
});
|
||||
el.body.appendChild(list);
|
||||
}
|
||||
|
||||
function renderPhotos() {
|
||||
var grid = document.createElement("div");
|
||||
grid.className = "grid";
|
||||
loaded.forEach(function (f) {
|
||||
var card = document.createElement("div");
|
||||
card.className = "card";
|
||||
|
||||
var frame = document.createElement("a");
|
||||
frame.className = "frame"; frame.href = f.url; frame.target = "_blank";
|
||||
frame.rel = "noreferrer";
|
||||
var img = document.createElement("img");
|
||||
img.src = f.url; img.alt = f.name; img.loading = "lazy";
|
||||
frame.appendChild(img);
|
||||
|
||||
var box = document.createElement("input");
|
||||
box.type = "checkbox";
|
||||
box.checked = !!selected[f.name];
|
||||
box.addEventListener("change", function () { toggle(f.name, box.checked); });
|
||||
frame.appendChild(box);
|
||||
|
||||
var meta = document.createElement("div"); meta.className = "meta";
|
||||
var who = document.createElement("span"); who.className = "who"; who.textContent = f.camera;
|
||||
var when = document.createElement("span"); when.className = "when"; when.textContent = f.when;
|
||||
var row = document.createElement("div"); row.className = "row";
|
||||
var size = document.createElement("span"); size.className = "size"; size.textContent = human(f.size);
|
||||
var get = document.createElement("a");
|
||||
get.className = "get"; get.href = f.url + "?download=1";
|
||||
get.setAttribute("download", f.name);
|
||||
get.textContent = "Download";
|
||||
row.appendChild(size); row.appendChild(get);
|
||||
meta.appendChild(who); meta.appendChild(when); meta.appendChild(row);
|
||||
|
||||
card.appendChild(frame); card.appendChild(meta);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
el.body.appendChild(grid);
|
||||
}
|
||||
|
||||
function switchKind(next) {
|
||||
if (kind === next) return;
|
||||
kind = next;
|
||||
el.tabRec.setAttribute("aria-pressed", String(kind === "recordings"));
|
||||
el.tabPho.setAttribute("aria-pressed", String(kind === "photos"));
|
||||
el.playNote.hidden = kind === "photos";
|
||||
el.camera.value = ""; el.day.value = "";
|
||||
setStatus("");
|
||||
load(true);
|
||||
}
|
||||
|
||||
el.tabRec.addEventListener("click", function () { switchKind("recordings"); });
|
||||
el.tabPho.addEventListener("click", function () { switchKind("photos"); });
|
||||
el.camera.addEventListener("change", function () { load(true); });
|
||||
el.day.addEventListener("change", function () { load(true); });
|
||||
el.more.addEventListener("click", function () { offset = loaded.length; load(false); });
|
||||
|
||||
el.selectAll.addEventListener("click", function () {
|
||||
var all = loaded.length && loaded.every(function (f) {
|
||||
return selected[kind === "photos" ? f.name : f.camera + "/" + f.name];
|
||||
});
|
||||
selected = {};
|
||||
if (!all) {
|
||||
loaded.forEach(function (f) {
|
||||
selected[kind === "photos" ? f.name : f.camera + "/" + f.name] = true;
|
||||
});
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
el.del.addEventListener("click", function () {
|
||||
var names = Object.keys(selected);
|
||||
if (!names.length) return;
|
||||
if (!window.confirm("Delete " + names.length +
|
||||
(names.length === 1 ? " file" : " files") + " permanently?")) return;
|
||||
el.del.disabled = true;
|
||||
setStatus("Deleting\u2026");
|
||||
fetch("/api/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ kind: kind, names: names })
|
||||
}).then(function (r) {
|
||||
return r.text().then(function (body) {
|
||||
if (!r.ok) throw new Error(body || "The server refused that.");
|
||||
return JSON.parse(body);
|
||||
});
|
||||
}).then(function (res) {
|
||||
var msg = "Deleted " + res.deleted + ", freeing " + human(res.freed) + ".";
|
||||
if (res.skipped && res.skipped.length) {
|
||||
msg += " " + res.skipped.length + " kept (still being recorded).";
|
||||
}
|
||||
setStatus(msg, "ok");
|
||||
selected = {};
|
||||
load(true);
|
||||
}).catch(function (err) {
|
||||
setStatus(String(err.message || err), "bad");
|
||||
refreshDelete();
|
||||
});
|
||||
});
|
||||
|
||||
load(true);
|
||||
setInterval(function () { if (!Object.keys(selected).length) load(true); }, 20000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user