From 0d101dec1dbde7c7fb468b5be7d4d435db32c2e8 Mon Sep 17 00:00:00 2001 From: localhorst Date: Fri, 15 Aug 2025 18:13:40 +0200 Subject: [PATCH] all-in-one backend using docker --- Dockerfile | 15 ++++++ README.md | 83 +++++++++++++++++++++++++++--- docker-compose.yml | 15 ++++++ msv-webcam-backend.service | 16 ------ msv_webcam_backend.py | 101 ------------------------------------- post_upload.sh | 39 ++++++++++++++ 6 files changed, 146 insertions(+), 123 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml delete mode 100644 msv-webcam-backend.service delete mode 100644 msv_webcam_backend.py create mode 100644 post_upload.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..50cff49 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM bfren/ftps:latest + +# Install required tools for Alpine +RUN apk add --no-cache \ + bash \ + inotify-tools \ + libavif-apps \ + findutils + +# Copy post-upload script +COPY post_upload.sh /usr/local/bin/post_upload.sh +RUN chmod +x /usr/local/bin/post_upload.sh + +# Launch script via bash +CMD ["bash", "/usr/local/bin/post_upload.sh"] diff --git a/README.md b/README.md index af19547..347da3b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,79 @@ # msv-webcam-backend -- `mkdir /opt/msv-webcam-backend/` -- `cd /opt/msv-webcam-backend/` -- import `msv_webcam_backend.py` -- `chmod +x /opt/msv-webcam-backend/msv_webcam_backend.py` -- `nano /etc/systemd/system/msv-webcam-backend.service` -- `systemctl daemon-reload && systemctl enable --now msv-webcam-backend.service` +A lightweight FTPS-based backend that automatically watches camera upload folders, converts the latest image from each camera to AVIF format, and stores them in a `current/` directory for easy access (e.g., via Nginx). + +--- + +## Features +- FTPS server for receiving image uploads +- Automatic detection of the newest image per camera folder +- Conversion to AVIF format using `avifenc` +- Persistent storage of converted images in a shared `current/` directory +- Designed for integration with a frontend or Nginx for live webcam feeds + +--- + +## Getting Started + +### 0) Import project files +Make sure the following files are present in your repository: +``` +docker-compose.yml +Dockerfile +post_upload.sh +``` + +### 1) Configure user credentials +Edit `docker-compose.yml` and set your FTPS username and password: +```yaml +environment: + - BF_FTPS_VSFTPD_USER=username + - BF_FTPS_VSFTPD_PASS=userpw +``` + +### 2) Make the post-upload script executable +```bash +chmod +x post_upload.sh +``` + +### 3) Build the Docker image +```bash +docker-compose build +``` + +### 4) Start the service +```bash +docker-compose up -d +``` + +--- + +## Directory Structure +``` +├── docker-compose.yml +├── Dockerfile +├── post_upload.sh +└── files/ + ├── msvcam1/ # uploads from camera 1 + ├── msvcam2/ # uploads from camera 2 + └── current/ # latest .avif images (msvcam1.avif, msvcam2.avif, ...) +``` + +--- + +## How It Works +1. Cameras upload images via FTPS into `files/msvcamN/` folders. +2. `post_upload.sh` periodically checks for the newest image in each folder. +3. If the newest image has changed (SHA256 hash comparison), it is converted to `.avif` format and saved to `files/current/` as `msvcamN.avif`. +4. The `current/` directory can be served directly by a web server (e.g., Nginx) for live display. + +--- + +## Example Nginx Integration +See [MSV Webcam Frontend](https://git.mosad.xyz/localhorst/msv-webcam-frontend) + +--- + +## Requirements +- Docker +- Docker Compose diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7cac99c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +services: + ftps: + build: . + restart: always + volumes: + - ./files:/files + environment: + - BF_FTPS_VSFTPD_USER=__PW_DB__ + - BF_FTPS_VSFTPD_PASS=__PW_DB__ + - BF_FTPS_EXTERNAL_URI=ftps.msv-buehl-moos.de + ports: + - "21:21" + - "990:990" + - "18700-18710:18700-18710" + diff --git a/msv-webcam-backend.service b/msv-webcam-backend.service deleted file mode 100644 index cf3385d..0000000 --- a/msv-webcam-backend.service +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=MSV-Webcam-Backend -After=syslog.target -After=network.target - -[Service] -Type=simple -User=root -Group=root -Restart=on-failure -RestartSec=5s -WorkingDirectory=/opt/msv-webcam-backend/ -ExecStart=/usr/bin/python3 /opt/msv-webcam-backend/msv_webcam_backend.py interval=10 search_dir=/home/dockeruser/ftps_server/files/msvcamS search_dir=/home/dockeruser/ftps_server/files/msvcamN destination_dir=/tmp/msv_webcam_current destination_owner=wwwrun - -[Install] -WantedBy=multi-user.target \ No newline at end of file diff --git a/msv_webcam_backend.py b/msv_webcam_backend.py deleted file mode 100644 index 38d4f04..0000000 --- a/msv_webcam_backend.py +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" Author: Hendrik Schutter, mail@hendrikschutter.com -""" - -import glob -import os -import time -import shutil -import sys - -signal_pipe_path = "/tmp/msv-webcam-signal-pipe" -signal_pipe_msg = "msv-webcam-new-images" - -def copy_image(src, destination_dir_path, destination_owner): - # print("src: " + str(src)) - # print("dst: " + str(destination_dir_path)) - os.makedirs(os.path.dirname(destination_dir_path), exist_ok=True) - shutil.copyfile(src, destination_dir_path) - os.chmod(destination_dir_path, 0o0644) - shutil.chown(destination_dir_path, destination_owner) - -def signal_new_images(): - if not os.path.exists(signal_pipe_path): - os.mkfifo(signal_pipe_path) - with open(signal_pipe_path, "w") as f: - f.write(signal_pipe_msg) - f.close() - -def main(): - print("starting ...") - - update_interval = -1 - destination_dir_path = -1 - destination_owner = -1 - search_dirs = list() - - for argument in sys.argv: - if argument.startswith('interval'): - update_interval = int(argument.split("=")[1]) - if argument.startswith('destination_dir'): - destination_dir_path = argument.split("=")[1] - if argument.startswith('destination_owner'): - destination_owner = argument.split("=")[1] - if argument.startswith('search_dir'): - tmp_search_dir = { - "search_dir_path" : argument.split("=")[1], - "newest_file_path" : "", - "newest_file_size" : 0 - } - search_dirs.append(tmp_search_dir) - - if ((update_interval == -1) or (destination_dir_path == -1) or (destination_owner == -1) or (len(search_dirs) == 0)): - print("Unable to parse config!") - print("Example usage:") - print(" python msv_webcam_backend.py interval=5 search_dir=test_images/msvcamS search_dir=test_images/msvcamN destination_dir=/tmp/msv_webcam_current destination_owner=hendrik") - sys.exit(-1) - - while True: - new_images_found = False - for search_dir in search_dirs: - try: - # print("\nSearch dir: " + str(search_dir["search_dir_path"])) - list_of_files = glob.glob(search_dir["search_dir_path"]+"/**/*.jpg", recursive=True) - # print(list_of_files) - newest_file_path_tmp = max(list_of_files, key=os.path.getctime) - newest_file_size_tmp = os.stat(newest_file_path_tmp).st_size - list_of_files.clear() - - if (newest_file_path_tmp == search_dir["newest_file_path"]) and (newest_file_size_tmp == search_dir["newest_file_size"]): - # already found the newest file - print("no newer file found") - else: - time.sleep(1) # wait 1sec to see if the upload is still in progress - - list_of_files = glob.glob(search_dir["search_dir_path"]+"/**/*.jpg", recursive=True) - newest_file_path_tmp_delayed = max(list_of_files, key=os.path.getctime) - newest_file_size_tmp_delayed = os.stat(newest_file_path_tmp_delayed).st_size - list_of_files.clear() - - if (newest_file_path_tmp == newest_file_path_tmp_delayed) and (newest_file_size_tmp == newest_file_size_tmp_delayed): - search_dir["newest_file_path"] = newest_file_path_tmp_delayed - search_dir["newest_file_size"] = newest_file_size_tmp_delayed - print("Found new file:") - print("Name: " + str(search_dir["newest_file_path"])) - print("Size: " + str(search_dir["newest_file_size"])) - dst_file_tmp = os.path.join(destination_dir_path, (os.path.basename(os.path.normpath(search_dir["search_dir_path"])) + os.path.splitext(search_dir["newest_file_path"])[1])) - copy_image(search_dir["newest_file_path"], dst_file_tmp, destination_owner) - new_images_found = True - #break - else: - print("Upload not finished yet!") - except Exception as e: - print(e) - #break - if(new_images_found == True): - signal_new_images() - time.sleep(update_interval) - -if __name__ == "__main__": - main() diff --git a/post_upload.sh b/post_upload.sh new file mode 100644 index 0000000..e8416b7 --- /dev/null +++ b/post_upload.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +BASE_DIR="/files" +CURRENT_DIR="$BASE_DIR/current" + +mkdir -p "$CURRENT_DIR" + +# Store last SHA256 checksums for each camera +declare -A LAST_SHA + +echo "[Watcher] Starting to monitor subfolders in $BASE_DIR ..." + +while true; do + for CAM_DIR in "$BASE_DIR"/msvcam*; do + [ -d "$CAM_DIR" ] || continue + + CAM_NAME=$(basename "$CAM_DIR") + OUT_FILE="$CURRENT_DIR/${CAM_NAME}.avif" + + # Find the newest image + NEWEST_FILE=$(find "$CAM_DIR" -type f -iregex ".*\.\(jpg\|jpeg\|png\|bmp\|tif\|tiff\)" -printf "%T@ %p\n" | sort -n | tail -1 | awk '{print $2}') + + if [ -n "$NEWEST_FILE" ]; then + # Calculate SHA256 of the newest image + SHA=$(sha256sum "$NEWEST_FILE" | awk '{print $1}') + + # Check if this file has already been converted + if [ "${LAST_SHA[$CAM_NAME]}" != "$SHA" ]; then + echo "[Watcher] New or changed file detected for $CAM_NAME: $NEWEST_FILE" + avifenc "$NEWEST_FILE" "$OUT_FILE" + sync + LAST_SHA[$CAM_NAME]="$SHA" + else + echo "[Watcher] No change detected for $CAM_NAME, skipping conversion." + fi + fi + done + sleep 5 +done