I’ve been thinking about getting some CCTV cameras for a while, and last summer I purchased two Reolink PoE cameras. The footage is stored on a SD card, and uploaded to a local FTP server.

But I also wanted to record continuously, without getting a dedicated Reolink NVR. I’ve seen some YouTube videos by Tall Paul Tech where he uses FFmpeg to record CCTV footage — so let’s do that! 🙂

Table of contents

Introduction

My plan was to set up a new container in Proxmox, and mount a dedicated 8 TB Seagate Skyhawk disk. Then run FFmpeg and record continuously to the disk — voila: NVR!

Proxmox container

After installing the Skyhawk disk, I initialized it with GPT and created a directory:

Creating disk directory in Proxmox

Then I logged into Proxmox with SSH and root — and mounted the disk to the container and changed the owner and group so the container had write access:

pct set 105 -mp0 /mnt/pve/skyhawk8,mp=/home/thomas/nvr

chown 101000:101000 /mnt/pve/skyhawk8/

When container is not privileged, UID and GID are not mapped. Container IDs gets 10000 added; so UID 1000 in container is 101000 on the host.

On the hypervisor:

drwxr-xr-x 4 101000 101000 4.0K Apr 15 09:58 ssd500

Inside container:

drwxr-xr-x 4 thomas thomas 4.0K Apr 15 09:58 nvr

FTP server

With the disk now mounted, I installed vsftpd FTP server:

sudo apt install vsftpd

And used the following configuration in /etc/vsftpd.conf:

listen=NO
listen_ipv6=YES
anonymous_enable=NO
local_enable=YES
write_enable=YES
dirmessage_enable=YES
use_localtime=YES
xferlog_enable=YES
connect_from_port_20=YES
secure_chroot_dir=/var/run/vsftpd/empty
pam_service_name=vsftpd
rsa_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem
rsa_private_key_file=/etc/ssl/private/ssl-cert-snakeoil.key
ssl_enable=NO

Now the CCTV cameras could be configured to store their video clips on the FTP server. I used the container user and password as log in credentials.

Streaming proxy

On to the NVR; instead of FFmpeg connecting directly to the cameras — I chose to set up a streaming proxy, called MediaMTX. I found that FFmpeg didn’t handle loosing the connection to the camera very well, and that seems to happen occasionally.

Instead; MediaMTX connects, and keeps an open connection, to the cameras — reconnecting if necessary. Then FFmpeg connects to MediaMTX.

Most IP cameras expose their video stream by using a RTSP server that is embedded into the camera itself. In particular, cameras that are compliant to ONVIF profile S or T meet this requirement. You can use MediaMTX to connect to one or multiple existing RTSP servers and read their video streams. — MediaMTX readme

Another advantage is that all other clients, like VNC, can connect to MediaMTX as well — without increasing the bandwidth usage on the cameras themselves. Reolink cameras only support two simultaneous streams in the highest quality.

I’m using the following configuration — connecting to both the main and sub RTSP streams:

readBufferCount: 4096

paths:
  driveway:
    source: rtsp://user:password@camera.i:a554/h265Preview_01_main
    sourceProtocol: tcp

  driveway_sub:
    source: rtsp://user:password@camera.ip:554/h264Preview_01_sub
    sourceProtocol: tcp
    #sourceOnDemand: yes

  garage:
    source: rtsp://user:password@camera.ip:554/h265Preview_01_main
    sourceProtocol: tcp

  garage_sub:
    source: rtsp://user:password@camera.ip:554/h264Preview_01_sub
    sourceProtocol: tcp
    #sourceOnDemand: yes
With the sourceOnDemand option — MediaMTX only connects to the source stream if needed.

FFmpeg

And finally; the actual NVR magic. Below is the FFmpeg script that connects to a camera video stream — and store it. One parameter is required; TARGET, this needs to match a path in MediaMTX.

#!/bin/bash
TARGET=$1

# Create output folder if missing, otherwise FFmpeg fails to start
[[ -d /home/thomas/nvr/$TARGET ]] || mkdir /home/thomas/nvr/$TARGET

ffmpeg -hide_banner -y \
    -loglevel error \
    -rtsp_transport tcp \
    -i rtsp://localhost:8554/$TARGET \
    -vcodec copy \
    -acodec copy \
    -map 0 \
    -f segment \
    -reset_timestamps 1 \
    -segment_time 300 \
    -segment_format mp4 \
    -segment_atclocktime 1 \
    -strftime 1 \
    /home/thomas/nvr/$TARGET/%Y%m%d_%H%M%S.mp4

Options explained:

  • -hide_banner: Don’t print copyright notice, build options, versions, etc.
  • -y: Overwrite output files without asking.
  • -rtsp_transport tcp: Use TCP as lower transport protocol.
  • -i rtsp://: Input stream, in our case the local streaming proxy
  • -vcodec/acodec copy: Copy video and audio, without re-encoding.
  • -map 0: Select all streams from input index #0 (the 1st input).
  • -f segment: Output should be split into segments.
  • -reset_timestamps 1: Reset timestamps at the beginning of each segment.
  • -segment_time 300: Set segment duration to 300 seconds.
  • -segment_format mp4: Set segment format to MP4.
  • -segment_atclocktime 1: Split at regular clock time intervals.
  • -strftime 1: Use the strftime function to define the name of the new segments to write.
  • Include year, month, day, hour, minute and second in output MP4 file.

We can use the same script for all cameras, simply by appending a different target/path parameter.

Supervisor

To make sure the streaming proxy, MediaMTX, and NVR scripts keep running — I’m using Supervisor. With the following applications and groups defined in /etc/supervisor/conf.d/nvr.conf:

[group:server]
programs=streaming
priority=1

[program:streaming]
command=/home/thomas/mediamtx cctv-stream.yml
directory=/home/thomas
autostart=true
autorestart=true
user=thomas
stderr_syslog=true
stdout_syslog=true

[group:nvr]
programs=driveway,garage
priority=10

[program:driveway]
command=/bin/bash nvr.sh driveway
directory=/home/thomas
autostart=true
autorestart=true
user=thomas
stderr_syslog=true
stdout_syslog=true
stopasgroup=true
stopsignal=QUIT
startretries=30
startsecs=5

[program:garage]
command=/bin/bash nvr.sh garage
directory=/home/thomas
autostart=true
autorestart=true
user=thomas
stderr_syslog=true
stdout_syslog=true
stopasgroup=true
stopsignal=QUIT
startretries=30
startsecs=5

The configuration has two groups: streaming and nvrstreaming has a lower priority value, and will be started first. This is important because the NVR scripts will fail if the streaming proxy is not running.

The programs in the nvr group use the same NVR bash script, but with different arguments. All programs are auto started, and restarted if they die.

Cleanup

To clean up old CCTV recordings, and organize NVR files, another bash script is used:

#!/bin/bash
CAMERAS=(driveway garage)

for cam in ${CAMERAS[@]}; do
  ## Clean up old FTP recordings
  if [ -d ~/cctv/$cam ]; then
    find ~/cctv/$cam/ -type f -mtime +7 -delete
    find ~/cctv/$cam/ -type d -empty -delete
  fi

  ## Clean up old NVR recordings
  if [ -d ~/nvr/$cam ]; then
    find ~/nvr/$cam/ -type f -mtime +7 -delete
    find ~/nvr/$cam/ -type d -empty -delete
  fi

  ## Organize NVR recordings
  if [ -d ~/nvr/$cam ]; then
    cd ~/nvr/$cam/
    for f in *.mp4; do
      date=${f:0:8}

      if [[ $date == $(date +%Y%m%d) ]]; then
        continue
      fi

      y=${date:0:4}
      m=${date:4:2}
      d=${date:6:2}

      target="$y/$m/$d"
      mkdir -p "$target"
      mv "$f" "$target"
    done
  fi
done

It deletes recordings older than 7 days, and organizes NVR video files into year/month/day folders. NVR recordings from the same day are not touched, as this could interfere with open files.

The cleanup scripts is scheduled to run every day — one minute over midnight:

$ crontab -l

# m h  dom mon dow   command
1 0 * * * /home/thomas/clean.sh

Home Assistant

I’ve also added the CCTV cameras to Home Assistant, as Generic Camera devices.

Generic Camera devices in Home Assistant

Home Assistant didn’t like the main stream, maybe because it is H.265, or maybe because it is 4K at 25 FPS. I’m instead using using the sub stream — which is H.264, 360p at 15 FPS.

Below is the device configuration for one of the cameras:

"options": {
  "use_wallclock_as_timestamps": false,
  "stream_source": "rtsp://cctv-nvr.hostname.lan:8554/garage_sub",
  "authentication": "basic",
  "framerate": 1.0,
  "verify_ssl": false,
  "limit_refetch_to_url_change": false,
  "rtsp_transport": "tcp",
  "content_type": "image/jpeg"
}
Garage camera in Home Assistant

Wrapping up

So there you have it — a simple, cheap, and effective NVR. Compatible with pretty much anything that has a video stream.

I’m quite happy with my Reolink cameras so far, but I don’t want to be tied into their ecosystem.

🖖