Initial Commit

Update Dockerfile_cleaner, Dockerfile_recorder, and 12 more files...
This commit is contained in:
Herwin Bozet (NibblePoker) 2023-05-15 17:07:19 +02:00
commit 6b3ef62c4b
14 changed files with 752 additions and 0 deletions

7
Dockerfile_cleaner Normal file
View File

@ -0,0 +1,7 @@
FROM python:3-alpine
RUN apk update && apk upgrade
WORKDIR /app
CMD ["python", "-u", "/app/app.py"]

11
Dockerfile_recorder Normal file
View File

@ -0,0 +1,11 @@
FROM alpine:latest
RUN apk update && apk upgrade && apk add --no-cache ffmpeg
WORKDIR /data
# For .mkv files
CMD ffmpeg -hide_banner -loglevel warning -threads 6 -avoid_negative_ts make_zero -fflags +genpts+discardcorrupt -rtsp_transport tcp -timeout 5000000 -use_wallclock_as_timestamps 1 -i "$NP_CCTV_URL" -f segment -segment_time 10 -reset_timestamps 1 -segment_format matroska -strftime 1 -c:v copy -tag:v hvcl -bsf:v hevc_mp4toannexb -c:a aac ./%Y-%m-%d_%H-%M-%S.mkv
# For .mp4 files (Unused)
#CMD ffmpeg -hide_banner -loglevel warning -threads 6 -avoid_negative_ts make_zero -fflags +genpts+discardcorrupt -rtsp_transport tcp -timeout 5000000 -use_wallclock_as_timestamps 1 -i "$NP_CCTV_URL" -f segment -segment_time 10 -reset_timestamps 1 -segment_format mp4 -strftime 1 -c:v copy -tag:v hvcl -bsf:v hevc_mp4toannexb -c:a aac ./%Y-%m-%d_%H-%M-%S.mp4

24
LICENSE.txt Normal file
View File

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

152
apache2.conf Normal file
View File

@ -0,0 +1,152 @@
#
# The directory where shm and other runtime files will be stored.
#
DefaultRuntimeDir ${APACHE_RUN_DIR}
#
# PidFile: The file in which the server should record its process
# identification number when it starts.
# This needs to be set in /etc/apache2/envvars
#
PidFile ${APACHE_PID_FILE}
#
# Timeout: The number of seconds before receives and sends time out.
#
Timeout 300
#
# KeepAlive: Whether or not to allow persistent connections (more than
# one request per connection). Set to "Off" to deactivate.
#
KeepAlive On
#
# MaxKeepAliveRequests: The maximum number of requests to allow
# during a persistent connection. Set to 0 to allow an unlimited amount.
# We recommend you leave this number high, for maximum performance.
#
MaxKeepAliveRequests 100
#
# KeepAliveTimeout: Number of seconds to wait for the next request from the
# same client on the same connection.
#
KeepAliveTimeout 5
# These need to be set in /etc/apache2/envvars
User ${APACHE_RUN_USER}
Group ${APACHE_RUN_GROUP}
#
# HostnameLookups: Log the names of clients or just their IP addresses
# e.g., www.apache.org (on) or 204.62.129.132 (off).
# The default is off because it'd be overall better for the net if people
# had to knowingly turn this feature on, since enabling it means that
# each client request will result in AT LEAST one lookup request to the
# nameserver.
#
HostnameLookups Off
# ErrorLog: The location of the error log file.
# If you do not specify an ErrorLog directive within a <VirtualHost>
# container, error messages relating to that virtual host will be
# logged here. If you *do* define an error logfile for a <VirtualHost>
# container, that host's errors will be logged there and not here.
#
ErrorLog ${APACHE_LOG_DIR}/error.log
#
# LogLevel: Control the severity of messages logged to the error_log.
# Available values: trace8, ..., trace1, debug, info, notice, warn,
# error, crit, alert, emerg.
# It is also possible to configure the log level for particular modules, e.g.
# "LogLevel info ssl:warn"
#
LogLevel warn
# Include module configuration:
IncludeOptional mods-enabled/*.load
IncludeOptional mods-enabled/*.conf
# Include list of ports to listen on
Include ports.conf
# Sets the default security model of the Apache2 HTTPD server. It does
# not allow access to the root filesystem outside of /usr/share and /var/www.
# The former is used by web applications packaged in Debian,
# the latter may be used for local directories served by the web server. If
# your system is serving content from a sub-directory in /srv you must allow
# access here, or in any related virtual host.
<Directory />
Options FollowSymLinks
AllowOverride None
Require all denied
</Directory>
<Directory /usr/share>
AllowOverride None
Require all granted
</Directory>
<Directory /var/www/>
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
</Directory>
#<Directory /srv/>
# Options Indexes FollowSymLinks
# AllowOverride None
# Require all granted
#</Directory>
# AccessFileName: The name of the file to look for in each directory
# for additional configuration directives. See also the AllowOverride
# directive.
#
AccessFileName .htaccess
#
# The following lines prevent .htaccess and .htpasswd files from being
# viewed by Web clients.
#
<FilesMatch "^\.ht">
Require all denied
</FilesMatch>
#
# The following directives define some format nicknames for use with
# a CustomLog directive.
#
# These deviate from the Common Log Format definitions in that they use %O
# (the actual bytes sent including headers) instead of %b (the size of the
# requested file), because the latter makes it impossible to detect partial
# requests.
#
# Note that the use of %{X-Forwarded-For}i instead of %h is not recommended.
# Use mod_remoteip instead.
#
LogFormat "%v:%p %h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" vhost_combined
LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combined
LogFormat "%h %l %u %t \"%r\" %>s %O" common
LogFormat "%{Referer}i -> %U" referer
LogFormat "%{User-agent}i" agent
# Include of directories ignores editors' and dpkg's backup files,
# see README.Debian for details.
# Include generic snippets of statements
IncludeOptional conf-enabled/*.conf
# Include the virtual host configurations:
IncludeOptional sites-enabled/*.conf
# vim: syntax=apache ts=4 sw=4 sts=4 sr noet

52
cleaner.py Normal file
View File

@ -0,0 +1,52 @@
import os
import signal
import sys
import time
# Keep files for 73 hours (3 days + 1 hours)
MAX_FILE_AGE_SECONDS = (72 + 1) * 60 * 60
# Once done cleaning, sleep for 5 minutes
SLEEP_TIME_SECONDS = 5 * 60
# Handling shutdown signals
def signal_handler(sig, frame):
sys.exit(0)
signal.signal(signal.SIGTERM, signal_handler)
def delete_old_files(directory, current_time):
deletion_count = 0
for filename in os.listdir(directory):
filepath = os.path.join(directory, filename)
if os.path.isfile(filepath):
file_creation_time = os.path.getctime(filepath)
if (current_time - file_creation_time) > (MAX_FILE_AGE_SECONDS):
os.remove(filepath)
deletion_count = deletion_count + 1
return deletion_count
print("Deleting old files...")
start_time = time.time()
file_deleted_count = 0
for item in os.listdir("/data/"):
item_path = os.path.join(folder_path, item)
if os.path.isdir(item_path):
file_deleted_count = file_deleted_count + delete_old_files(item_path, start_time)
else:
# Ignoring files
continue
end_time = time.time()
print("Took {} second(s) to delete {} file(s)".format(round(end_time - start_time, 2), file_deleted_count))
time.sleep(SLEEP_TIME_SECONDS)

51
docker-compose.yml Normal file
View File

@ -0,0 +1,51 @@
version: "3"
services:
cctv_recorder_cam1:
container_name: cctv-recorder-cam1
build:
context: .
dockerfile: Dockerfile_recorder
environment:
- TZ=Europe/Brussels
- "NP_CCTV_URL=rtsp://user:password@address:554/sub-path"
volumes:
- ./recordings/cam1:/data
restart: unless-stopped
cctv_recorder_cam2:
container_name: cctv-recorder-cam2
build:
context: .
dockerfile: Dockerfile_recorder
environment:
- TZ=Europe/Brussels
- "NP_CCTV_URL=rtsp://user:password@address:554"
volumes:
- ./recordings/cam2:/data
restart: unless-stopped
cctv_cleaner:
container_name: cctv-cleaner
build:
context: .
dockerfile: Dockerfile_cleaner
environment:
- TZ=Europe/Brussels
volumes:
- ./recordings:/data
- ./cleaner.py:/app/app.py:ro
restart: unless-stopped
cctv_web:
container_name: cctv-web
image: php:apache
ports:
- 26880:80
environment:
- TZ=Europe/Brussels
volumes:
- ./htdocs:/var/www/html # Cannot be "ro" since the recordings are mounted into it.
- ./apache2.conf:/etc/apache2/apache2.conf:ro
- ./recordings:/var/www/html/data:ro
restart: unless-stopped

11
htdocs/.htaccess Normal file
View File

@ -0,0 +1,11 @@
# Prevent access to .htaccess
<Files ~ "^.*\.([Hh][Tt][Aa]|[Pp][Yy])">
Require all denied
</Files>
# Fixing Apache's autistic fit with .mkv files
AddType video/x-matroska mkv
# Allowing indexes
Options +Indexes -FollowSymlinks -ExecCGI
ServerSignature Off

1
htdocs/css/simplette.all.min.css vendored Normal file

File diff suppressed because one or more lines are too long

BIN
htdocs/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

1
htdocs/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

365
htdocs/index.php Normal file
View File

@ -0,0 +1,365 @@
<?php
// List of all available cameras
$camsInfo = [
# Format: [camId, camName]
["cam1", "Cam #1"],
["cam2", "Cam #2"]
];
// Root location of all recordings. (Not used yet)
$rootLocation = "./data/";
// Grabbing the requested cam's ID
// The id should be the same as the sub-folder into which this camera's recordings are located.
$camId = $_GET['cam'] ?? null;
// Determining if the ID is valid and the name that should be shown.
// If the ID is invalid, it is set back to `null`.
if(is_null($camId)) {
$camName = "None";
} else {
$isCamValid = false;
foreach ($camsInfo as $singleCamInfo) {
if($singleCamInfo[0] == $camId) {
$camName = $singleCamInfo[1];
$isCamValid = true;
break;
}
}
if(!$isCamValid) {
$camName = "Unknown";
$camId = null;
}
}
// Grabbing the list of recordings if needed.
if(is_null($camId)) {
// No cam selected, we just use empty variables.
$basePath = "./";
$files = [];
} else {
$basePath = "/data/".$camId."/";
$files = array_values(array_diff(scandir("./data/".$camId."/"), array('.', '..')));
// Removing the newest one as it is highly likely to currently being written to by ffmpeg.
array_pop($files);
}
// If we only need to send the JSON, we send it and don't go further.
$returnJsonOnly = !is_null($_GET['json'] ?? null);
if($returnJsonOnly) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode($files);
exit();
}
// Function used to calculate the disk space taken by recordings later on.
function folderSize($dir){
$count_size = 0;
$count = 0;
$dir_array = scandir($dir);
foreach($dir_array as $key=>$filename){
if($filename!=".." && $filename!="."){
if(is_dir($dir."/".$filename)){
$new_foldersize = foldersize($dir."/".$filename);
$count_size = $count_size+ $new_foldersize;
} else if(is_file($dir."/".$filename)) {
$count_size = $count_size + filesize($dir."/".$filename);
$count++;
}
}
}
return $count_size;
}
// Function used to format the disk space taken by recordings later on.
function sizeFormat($bytes) {
$kb = 1024;
$mb = $kb * 1024;
$gb = $mb * 1024;
$tb = $gb * 1024;
if (($bytes >= 0) && ($bytes < $kb)) {
return $bytes . ' B';
} elseif (($bytes >= $kb) && ($bytes < $mb)) {
return ceil($bytes / $kb) . ' KiB';
} elseif (($bytes >= $mb) && ($bytes < $gb)) {
return ceil($bytes / $mb) . ' MiB';
} elseif (($bytes >= $gb) && ($bytes < $tb)) {
return ceil($bytes / $gb) . ' GiB';
} elseif ($bytes >= $tb) {
return ceil($bytes / $tb) . ' TiB';
} else {
return $bytes . ' B';
}
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>NibblePoker's Mini CCTV NVR</title>
<link rel="stylesheet" href="/css/simplette.all.min.css">
<style>
#video-selector {
min-width: 95%;
}
video {
max-height: 60vh;
max-width: 95vw;
border-radius: 0.5em;
}
#skippers a {
margin-left: 1em;
margin-right: 1em;
user-select: none;
}
input[type=range] {
box-shadow: none;
}
#video-caching {
display: none;
}
</style>
</head>
<body>
<nav class="margin-container">
<ul class="link-list">
<li><a href="/">Home</a></li>
<li><a href="/data">Raw recordings</a></li>
<?php
// Adding the cameras in the navbar
foreach ($camsInfo as $singleCamInfo) {
echo("<li><a href=\"/?cam=" . $singleCamInfo[0] . "\">" . $singleCamInfo[1] . "</a></li>");
}
?>
</ul>
</nav>
<header><h1><b>NibblePoker's Mini CCTV NVR</b></h1></header>
<hr><hr>
<div class="margin-container auto-paragraph-margin">
<table style="width: 100%;">
<tr>
<td>
<h3 style="width: 100%;">Caméra: <i><?php echo($camName); ?></i></h3>
</td>
<td>
<span style="float: right;"><?php
// Printing the space taken by all cams and the current one if possible.
$totalSize = sizeFormat(folderSize("./data/"));
if(is_null($camId)) {
echo($totalSize);
} else {
echo(sizeFormat(folderSize("./data/" . $camId . "/")) . " / " . $totalSize);
}
?></span>
</td>
</tr>
</table>
</div>
<hr>
<div class="margin-container">
<?php
if(is_null($camId)) {
// No camera selected.
echo("<p class=\"h5\" style=\"margin-bottom: 0.5em;\">Select one camera:</p>");
echo("<ul class=\"link-list h5 indent-container\">");
// Adding the cameras in the URL list
foreach ($camsInfo as $singleCamInfo) {
echo("<li><a href=\"/?cam=" . $singleCamInfo[0] . "\">" . $singleCamInfo[1] . "</a></li>");
}
echo("</ul>");
} else {
// We have selected one, we add the video, slider, jumpers and filename placeholder.
echo("<center>");
echo("<video id=\"cctv-out\" controls></video>");
echo("<br>");
echo("<input type=\"range\" id=\"video-selector\" min=\"0\" max=\"" . count($files) . "\" value=\"0\">");
echo("<br>");
echo("<p id=\"skippers\">");
echo("<a id=\"skip-minus-25\">&lt;&lt;&lt;&lt; 25</a>");
echo("<a id=\"skip-minus-10\">&lt;&lt;&lt; 10</a>");
echo("<a id=\"skip-minus-5\">&lt;&lt; 5</a>");
echo("<a id=\"skip-minus-1\">&lt; 1</a>");
echo("<a id=\"skip-plus-1\">1 &gt;</a>");
echo("<a id=\"skip-plus-5\">5 &gt;&gt;</a>");
echo("<a id=\"skip-plus-10\">10 &gt;&gt;&gt;</a>");
echo("<a id=\"skip-plus-25\">25 &gt;&gt;&gt;&gt;</a>");
echo("</p>");
echo("<br>");
echo("<p>File: <a id=\"url-video\" href=\"#\">Non définis</a> (<span id=\"vid-count-current\">0</span>/<span id=\"vid-count-total\">0</span>)</p>");
echo("</center>");
echo("<br>");
}
?>
</div>
<hr><hr>
<footer>
<!-- Feel free to change this to something less invasive, or simply stats. Your imagination is the limit :) -->
<p>Made by <a href="https://github.com/aziascreations">BOZET Herwin</a> on <a href="https://github.com/aziascreations/Docker-Mini-CCTV-NVR">Github</a></p>
</footer>
<script>
<?php
// Adding the base path and initial file listing as JS variables.
echo("const basePath = \"" . $basePath . "\";");
echo("let files = " . json_encode($files) . ";");
?>
const videoCaching = document.createElement('video');
const videoListUpdateIntervalMs = 10 * 1000;
const startOffset = 2;
let iCurrentVideo = files.length - startOffset;
if(iCurrentVideo < 0) {
iCurrentVideo = 0;
}
document.addEventListener('DOMContentLoaded', function() {
videoCaching.preload = 'auto';
videoCaching.id = 'video-caching';
const eVideo = document.getElementById("cctv-out");
const eVideoSelector = document.getElementById("video-selector");
// If we have a video element in the DOM, we set it up.
if(eVideo !== null) {
// Handles every change of video
const playNextVideo = () => {
if(files.length > 0 && iCurrentVideo <= files.length) {
const newSource = basePath + files[iCurrentVideo];
eVideo.src = newSource;
// Setting the MIME type on the visible player, just in case.
if(newSource.endsWith(".mkv")) {
eVideo.type = 'video/x-matroska';
} else if(newSource.endsWith(".mp4")) {
eVideo.type = 'video/mp4';
} else {
eVideo.type = '';
}
eVideoSelector.value = iCurrentVideo;
document.getElementById("url-video").href = newSource;
document.getElementById("url-video").text = newSource;
document.getElementById("vid-count-current").textContent = iCurrentVideo;
eVideo.play();
// If there is a next video in the list, we attempt to cache it via a hidden player.
if(iCurrentVideo + 1 < files.length) {
videoCaching.preload = 'auto';
videoCaching.src = basePath + files[iCurrentVideo + 1];
// Setting the MIME type on the caching player, just in case.
if(files[iCurrentVideo + 1].endsWith(".mkv")) {
videoCaching.type = 'video/x-matroska';
} else if(files[iCurrentVideo + 1].endsWith(".mp4")) {
videoCaching.type = 'video/mp4';
} else {
videoCaching.type = '';
}
}
}
};
// Repeated function that updates the list of available videos every now and then.
const updateVideoList = () => {
fetch(window.location + "&json=1")
.then(response => {
if(!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
let newIndex = data.indexOf(files[iCurrentVideo]);
files = data;
if(newIndex === -1) {
newIndex = files.length - startOffset;
}
iCurrentVideo = newIndex;
eVideoSelector.value = iCurrentVideo;
eVideoSelector.max = files.length;
document.getElementById("vid-count-current").textContent = iCurrentVideo;
document.getElementById("vid-count-total").textContent = files.length;
setTimeout(updateVideoList, videoListUpdateIntervalMs);
})
.catch(error => {
setTimeout(updateVideoList, videoListUpdateIntervalMs);
});
};
// Trigerred when a video ends.
eVideo.addEventListener("ended", () => {
iCurrentVideo++;
playNextVideo();
});
// Trigerred every time a video plays.
// Used to keep the video's frame at a constant size.
// It looks like ass otherwise since it "flickers" between 2 sizes.
eVideo.addEventListener("playing", function() {
eVideo.width = eVideo.offsetWidth;
eVideo.height = eVideo.offsetHeight;
eVideo.style.minWidth = eVideo.offsetWidth+"px";
eVideo.style.minHeight = eVideo.offsetHeight+"px";
});
// Changes the "current video" number when moving the slider.
eVideoSelector.oninput = function() {
document.getElementById("vid-count-current").textContent = eVideoSelector.value;
};
// Plays the correct video once the slider is released.
eVideoSelector.onchange = function() {
iCurrentVideo = eVideoSelector.value;
playNextVideo();
};
// Quick jumps
document.getElementById('skip-minus-25').addEventListener('click', function() {
iCurrentVideo = Math.max(0, iCurrentVideo - 25);
playNextVideo();
});
document.getElementById('skip-minus-10').addEventListener('click', function() {
iCurrentVideo = Math.max(0, iCurrentVideo - 10);
playNextVideo();
});
document.getElementById('skip-minus-5').addEventListener('click', function() {
iCurrentVideo = Math.max(0, iCurrentVideo - 5);
playNextVideo();
});
document.getElementById('skip-minus-1').addEventListener('click', function() {
iCurrentVideo = Math.max(0, iCurrentVideo - 1);
playNextVideo();
});
document.getElementById('skip-plus-1').addEventListener('click', function() {
iCurrentVideo = Math.min(files.length - 1, iCurrentVideo + 1);
playNextVideo();
});
document.getElementById('skip-plus-5').addEventListener('click', function() {
iCurrentVideo = Math.min(files.length - 1, iCurrentVideo + 5);
playNextVideo();
});
document.getElementById('skip-plus-10').addEventListener('click', function() {
iCurrentVideo = Math.min(files.length - 1, iCurrentVideo + 10);
playNextVideo();
});
document.getElementById('skip-plus-25').addEventListener('click', function() {
iCurrentVideo = Math.min(files.length - 1, iCurrentVideo + 25);
playNextVideo();
});
// Starting up the player, the list updater loop and setting the currently played vid number.
document.getElementById("vid-count-total").textContent = files.length;
playNextVideo();
setTimeout(updateVideoList, videoListUpdateIntervalMs);
}
});
</script>
</body>
</html>

77
readme.md Normal file
View File

@ -0,0 +1,77 @@
# Mini CCTV NRV
A mini docker stack that allows you to easily record, clean and serve CCTV recordings made over RSTP while using a
minimal amount of system resources.
## Preamble
This stack records the camera's streams as-is and doens't re-encode or compress it which can cause it to use more disk space.<br>
The highest I've got on my side is around 70 GiB per day for a 4K-ish cam with AAC audio.
It is highly recommended to put the web page behind a secure reverse-proxy that requires authentication for aditional security.
## Setup
Since the stack is a bit rough around the edges for simplicity''s sake you **will** need to setup a couple of things beforehand.
It should only take 2-3 minutes if you already have the RTSP URL on hand however.
### Cameras
Each recording container needs to be given a RSTP stream URL and a unique folder into which the recordings will go.
The URL must be given via the `NP_CCTV_URL` environment variable, and the output folder via a mounted volume that is
mounted as `/data` in the container.
Here is a simple example:
```yaml
cctv_recorder_cam1:
container_name: cctv-recorder-cam1
build:
context: .
dockerfile: Dockerfile_recorder
environment:
- TZ=Europe/Brussels
- "NP_CCTV_URL=rtsp://user:password@address:554/sub-path"
volumes:
- ./recordings/cam1:/data
restart: unless-stopped
```
This example will use the `rtsp://user:password@address:554/sub-path` URL and will put its recordings in `./recordings/cam1`.
### Cleaner
The cleaner script named [cleaner.py](cleaner.py) only requires you to change 1 variable located near the top of the file.
The variable named `MAX_FILE_AGE_SECONDS` is used to indicate how long recordings should be kept for and is set to
73 hours by default.
It can also be changed as you wish without recreating the stack since the script is "reloaded" on each run.
### Web interface
The web interface only requires you to give it the list of all cams' IDs and a friendly name in the `$camsInfo` variable.
This variable is located at the top of the [htdocs/index.php](htdocs/index.php) file and should look like this:
```php
$camsInfo = [
# Format: [camId, camName]
["cam1", "Cam #1"],
["cam2", "Cam #2"]
];
```
The cam's ID refers to a subfolder of `./recordings` into which this cam's recordings are found as `.mp4` or `.mkv` files.
## Startup
Once you have finished setting up the stack, you can simply run the following command:
```bash
docker-compose up --build -d
```
## Screenshots
### Home page
![alt text](screenshots/home.png)
### Camera's page with blurred preview
![alt text](screenshots/cam.png)
## License
This software, as well as the [Simplette CSS Stylesheet](https://github.com/aziascreations/Simplette-CSS-Stylesheet)
used for the web interface are both licensed under [Unlicense](LICENSE).

BIN
screenshots/cam.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
screenshots/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB