Initial Commit
Update Dockerfile_cleaner, Dockerfile_recorder, and 12 more files...
This commit is contained in:
commit
6b3ef62c4b
7
Dockerfile_cleaner
Normal file
7
Dockerfile_cleaner
Normal 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
11
Dockerfile_recorder
Normal 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
24
LICENSE.txt
Normal 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
152
apache2.conf
Normal 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
52
cleaner.py
Normal 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
51
docker-compose.yml
Normal 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
11
htdocs/.htaccess
Normal 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
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
BIN
htdocs/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
1
htdocs/favicon.svg
Normal file
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
365
htdocs/index.php
Normal 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\"><<<< 25</a>");
|
||||||
|
echo("<a id=\"skip-minus-10\"><<< 10</a>");
|
||||||
|
echo("<a id=\"skip-minus-5\"><< 5</a>");
|
||||||
|
echo("<a id=\"skip-minus-1\">< 1</a>");
|
||||||
|
echo("<a id=\"skip-plus-1\">1 ></a>");
|
||||||
|
echo("<a id=\"skip-plus-5\">5 >></a>");
|
||||||
|
echo("<a id=\"skip-plus-10\">10 >>></a>");
|
||||||
|
echo("<a id=\"skip-plus-25\">25 >>>></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
77
readme.md
Normal 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
BIN
screenshots/cam.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
BIN
screenshots/home.png
Normal file
BIN
screenshots/home.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
Loading…
Reference in New Issue
Block a user