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