This commit is contained in:
jpmvaz
2026-09-13 20:24:20 +01:00
commit 2015c4911f
9 changed files with 4403 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
data/
cameras.json
__pycache__/
*.pyc
README.md
+33
View File
@@ -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"]
+410
View File
@@ -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 +/&minus; buttons | <kbd>+</kbd> <kbd>&minus;</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
View File
@@ -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="/">&larr; 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 &nbsp;<a href="/files" style="color:var(--phosphor);text-decoration:none;font-size:10px;letter-spacing:.18em">browse all &rarr;</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>
+28
View File
@@ -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
BIN
View File
Binary file not shown.
+1376
View File
File diff suppressed because it is too large Load Diff
+443
View File
@@ -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>
+1585
View File
File diff suppressed because it is too large Load Diff