refactor: pivot to JSON config — no yaml dependency, jq in bash, greenfield detection

This commit is contained in:
Claus Lohmar 2026-08-06 14:54:43 +01:00
parent 386d3b3858
commit b2e4dface2
8 changed files with 196 additions and 334 deletions

6
api.go
View file

@ -79,7 +79,7 @@ func (s *Server) handleCameraByID(w http.ResponseWriter, r *http.Request) {
// handleConfig handles configuration read/write. // handleConfig handles configuration read/write.
// GET /api/config — return current config (without passwords) // GET /api/config — return current config (without passwords)
// POST /api/config — save full config to config.yaml // POST /api/config — save full config to config.json
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
@ -126,9 +126,9 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
*s.appConfig = newCfg *s.appConfig = newCfg
if err := SaveConfig(*s.appConfig, "/opt/nextnvr/config.yaml"); err != nil { if err := SaveConfig(*s.appConfig, "/opt/nextnvr/config.json"); err != nil {
// Try saving to the app's config path. // Try saving to the app's config path.
_ = SaveConfig(*s.appConfig, "config.yaml") _ = SaveConfig(*s.appConfig, "config.json")
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "config saved"}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "config saved"})

106
config.go
View file

@ -1,75 +1,67 @@
// NextNVR — MIT License // NextNVR — MIT License
// Copyright (c) 2026 NextNVR Contributors // Copyright (c) 2026 NextNVR Contributors
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
//
// Handles config.yaml parsing with sensible defaults // config.go — JSON configuration management. Zero external dependencies.
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"gopkg.in/yaml.v3"
) )
// Config represents the top-level application configuration. // Config represents the top-level application configuration.
type Config struct { type Config struct {
Server ServerConfig `yaml:"server" json:"server"` Server ServerConfig `json:"server"`
Storage StorageConfig `yaml:"storage" json:"storage"` Storage StorageConfig `json:"storage"`
Auth AuthConfig `yaml:"auth" json:"auth"` Auth AuthConfig `json:"auth"`
Cameras []CameraConfig `yaml:"cameras" json:"cameras"` Cameras []CameraConfig `json:"cameras"`
Go2RTC Go2RTCConfig `yaml:"go2rtc" json:"go2rtc"` Go2RTC Go2RTCConfig `json:"go2rtc"`
} }
// ServerConfig holds HTTP server settings.
type ServerConfig struct { type ServerConfig struct {
Port string `yaml:"port" json:"port"` Port string `json:"port"`
BindHost string `yaml:"bind_host" json:"bind_host"` BindHost string `json:"bind_host"`
ViewerPort string `yaml:"viewer_port" json:"viewer_port"` ViewerPort string `json:"viewer_port"`
} }
// StorageConfig holds recording and retention settings.
type StorageConfig struct { type StorageConfig struct {
RecordingsPath string `yaml:"recordings_path" json:"recordings_path"` RecordingsPath string `json:"recordings_path"`
RetentionDays int `yaml:"retention_days" json:"retention_days"` RetentionDays int `json:"retention_days"`
CleanupIntervalMins int `yaml:"cleanup_interval_mins" json:"cleanup_interval_mins"` CleanupIntervalMins int `json:"cleanup_interval_mins"`
} }
// CameraConfig represents a single camera's configuration.
type CameraConfig struct { type CameraConfig struct {
ID string `yaml:"id" json:"id"` ID string `json:"id"`
Name string `yaml:"name" json:"name"` Name string `json:"name"`
IP string `yaml:"ip" json:"ip"` IP string `json:"ip"`
Username string `yaml:"username" json:"username"` Username string `json:"username"`
Password string `yaml:"password" json:"password"` Password string `json:"password"`
ONVIFPort int `yaml:"onvif_port" json:"onvif_port,string"` ONVIFPort int `json:"onvif_port,string"`
RTSPMain string `yaml:"rtsp_main" json:"rtsp_main"` RTSPMain string `json:"rtsp_main"`
RTSPSub string `yaml:"rtsp_sub" json:"rtsp_sub"` RTSPSub string `json:"rtsp_sub"`
Description string `yaml:"description" json:"description"` Description string `json:"description"`
Enabled bool `yaml:"enabled" json:"enabled"` Enabled bool `json:"enabled"`
Record bool `yaml:"record" json:"record"` Record bool `json:"record"`
} }
// Go2RTCConfig holds go2rtc child process settings.
type Go2RTCConfig struct { type Go2RTCConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"` Enabled bool `json:"enabled"`
Port string `yaml:"port" json:"port"` Port string `json:"port"`
Binary string `yaml:"binary" json:"binary"` Binary string `json:"binary"`
} }
// AuthConfig holds authentication settings.
type AuthConfig struct { type AuthConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"` Enabled bool `json:"enabled"`
Master UserConfig `yaml:"master" json:"master"` Master UserConfig `json:"master"`
Viewer UserConfig `yaml:"viewer" json:"viewer"` Viewer UserConfig `json:"viewer"`
} }
// UserConfig holds a single user's credentials.
type UserConfig struct { type UserConfig struct {
Username string `yaml:"username" json:"username"` Username string `json:"username"`
Password string `yaml:"password" json:"password"` // bcrypt hash Password string `json:"password"`
Enabled bool `yaml:"enabled" json:"enabled"` Enabled bool `json:"enabled"`
} }
// APIResponse wraps all JSON API responses. // APIResponse wraps all JSON API responses.
@ -96,20 +88,23 @@ func DefaultConfig() Config {
ViewerPort: ":8090", ViewerPort: ":8090",
}, },
Storage: StorageConfig{ Storage: StorageConfig{
RecordingsPath: "/mnt/recordings", RecordingsPath: "/home/master/nvr_data",
RetentionDays: 7, RetentionDays: 7,
CleanupIntervalMins: 60, CleanupIntervalMins: 60,
}, },
Auth: AuthConfig{
Enabled: false,
Viewer: UserConfig{Enabled: true},
},
Go2RTC: Go2RTCConfig{ Go2RTC: Go2RTCConfig{
Enabled: true, Enabled: true,
Port: ":1984", Port: ":1984",
Binary: "go2rtc", Binary: "/opt/nextnvr/go2rtc",
}, },
} }
} }
// LoadConfig reads and parses a YAML configuration file. // LoadConfig reads and parses a JSON configuration file.
// If the file does not exist, it returns DefaultConfig().
func LoadConfig(path string) (Config, error) { func LoadConfig(path string) (Config, error) {
cfg := DefaultConfig() cfg := DefaultConfig()
@ -121,31 +116,22 @@ func LoadConfig(path string) (Config, error) {
return cfg, fmt.Errorf("reading config: %w", err) return cfg, fmt.Errorf("reading config: %w", err)
} }
if err := yaml.Unmarshal(data, &cfg); err != nil { if err := json.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parsing config: %w", err) return cfg, fmt.Errorf("parsing config: %w", err)
} }
// Apply path defaults relative to config file location.
configDir := filepath.Dir(path)
if cfg.Storage.RecordingsPath == "" {
cfg.Storage.RecordingsPath = "/mnt/recordings"
}
_ = configDir // reserved for relative path resolution
return cfg, nil return cfg, nil
} }
// SaveConfig writes the configuration to a YAML file. // SaveConfig writes the configuration to a JSON file.
func SaveConfig(cfg Config, path string) error { func SaveConfig(cfg Config, path string) error {
data, err := yaml.Marshal(&cfg) data, err := json.MarshalIndent(&cfg, "", " ")
if err != nil { if err != nil {
return fmt.Errorf("marshaling config: %w", err) return fmt.Errorf("marshaling config: %w", err)
} }
data = append(data, '\n')
header := []byte("# NextNVR Configuration\n# Generated by NextNVR setup\n\n") if err := os.WriteFile(path, data, 0600); err != nil {
out := append(header, data...)
if err := os.WriteFile(path, out, 0600); err != nil {
return fmt.Errorf("writing config: %w", err) return fmt.Errorf("writing config: %w", err)
} }
return nil return nil

31
config.json.sample Normal file
View file

@ -0,0 +1,31 @@
{
"server": {
"port": ":8080",
"bind_host": "0.0.0.0",
"viewer_port": ":8090"
},
"storage": {
"recordings_path": "",
"retention_days": 7,
"cleanup_interval_mins": 60
},
"auth": {
"enabled": false,
"master": {
"username": "",
"password": "",
"enabled": false
},
"viewer": {
"username": "",
"password": "",
"enabled": true
}
},
"go2rtc": {
"enabled": true,
"port": ":1984",
"binary": "/opt/nextnvr/go2rtc"
},
"cameras": []
}

View file

@ -1,29 +0,0 @@
# NextNVR Configuration
# Edit this file or use the web UI (Settings tab) to configure cameras.
server:
port: ":8080"
bind_host: "0.0.0.0"
viewer_port: ":8090"
storage:
recordings_path: "/home/master/nvr_data"
retention_days: 7
cleanup_interval_mins: 60
auth:
enabled: false
master:
username: ""
password: ""
viewer:
enabled: true
username: ""
password: ""
go2rtc:
enabled: false
port: ":1984"
binary: "go2rtc"
cameras: []

View file

@ -3,8 +3,8 @@
# Usage: curl -sSLO https://git.lohmar.co.uk/cclohmar/NextNVR/raw/branch/main/deploy_nvr.sh # Usage: curl -sSLO https://git.lohmar.co.uk/cclohmar/NextNVR/raw/branch/main/deploy_nvr.sh
# chmod +x deploy_nvr.sh && ./deploy_nvr.sh # chmod +x deploy_nvr.sh && ./deploy_nvr.sh
# #
# Supports: Debian, Ubuntu, Fedora, RHEL, Rocky, Alma, Arch, Alpine # Greenfield: clones repo, prompts for storage, builds config, installs service.
# Requires: curl, sudo, systemd (or falls back to nohup for non-systemd) # Update: kills old instance, pulls latest, rebuilds, restarts (config preserved).
set -e set -e
GIT_URL="https://git.lohmar.co.uk/cclohmar/NextNVR.git" GIT_URL="https://git.lohmar.co.uk/cclohmar/NextNVR.git"
@ -12,16 +12,11 @@ DEPLOY_DIR="/opt/nextnvr"
SERVICE_NAME="nextnvr" SERVICE_NAME="nextnvr"
GO_VERSION="1.24.5" GO_VERSION="1.24.5"
GO_ARCH="amd64" GO_ARCH="amd64"
GO2RTC_VER="1.9.14"
# Detect ARM: use arm64 if running on aarch64. [ "$(uname -m)" = "aarch64" ] && GO_ARCH="arm64"
if [ "$(uname -m)" = "aarch64" ]; then GO_ARCH="arm64"; fi
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
log() { echo -e "${GREEN}[NextNVR]${NC} $1"; } log() { echo -e "${GREEN}[NextNVR]${NC} $1"; }
warn() { echo -e "${YELLOW}[NextNVR]${NC} $1"; } warn() { echo -e "${YELLOW}[NextNVR]${NC} $1"; }
err() { echo -e "${RED}[NextNVR]${NC} $1"; } err() { echo -e "${RED}[NextNVR]${NC} $1"; }
@ -29,30 +24,14 @@ info() { echo -e "${CYAN}[NextNVR]${NC} $1"; }
echo "" echo ""
echo "============================================" echo "============================================"
echo " NextNVR — Portable Deployment" echo " NextNVR — Deploy / Update"
echo " $(uname -m) / $(uname -s)" echo " $(uname -m) / $(uname -s)"
echo "============================================" echo "============================================"
echo "" echo ""
# ── 0. Stop existing service (clean update) ── # ── 0. Check sudo ──
if systemctl is-active --quiet "${SERVICE_NAME}" 2>/dev/null; then
log "Stopping existing NextNVR service..."
sudo systemctl stop "${SERVICE_NAME}"
log "Service stopped."
elif pgrep -f "/opt/nextnvr/nextnvr" > /dev/null 2>&1; then
warn "NextNVR is running outside systemd. Stopping..."
sudo pkill -f "/opt/nextnvr/nextnvr" 2>/dev/null || true
sleep 2
fi
# Clean up any lingering processes.
sudo pkill -f "go2rtc" 2>/dev/null || true
sudo pkill -f "ffmpeg.*rec_" 2>/dev/null || true
# ── Check for sudo ──
if ! command -v sudo &>/dev/null; then if ! command -v sudo &>/dev/null; then
if [ "$(id -u)" -eq 0 ]; then if [ "$(id -u)" -eq 0 ]; then
# Running as root — create a no-op sudo.
sudo() { "$@"; } sudo() { "$@"; }
else else
err "sudo is required. Install it first or run as root." err "sudo is required. Install it first or run as root."
@ -60,207 +39,134 @@ if ! command -v sudo &>/dev/null; then
fi fi
fi fi
# ── 0. Detect distro & package manager ── # ── 1. Kill all existing instances ──
detect_pkg_manager() { log "Stopping any running NextNVR instances..."
if command -v apt-get &>/dev/null; then sudo systemctl stop "${SERVICE_NAME}" 2>/dev/null || true
PKG_INSTALL="sudo apt-get update -qq && sudo apt-get install -y -qq" sudo pkill -9 -f "nextnvr" 2>/dev/null || true
PKG_SEARCH="apt-cache search" sudo pkill -9 -f "go2rtc" 2>/dev/null || true
FFMPEG_PKG="ffmpeg" sudo pkill -9 -f "ffmpeg.*rec_" 2>/dev/null || true
GIT_PKG="git" sleep 2
CURL_PKG="curl"
DISTRO="debian"
elif command -v dnf &>/dev/null; then
PKG_INSTALL="sudo dnf install -y -q"
PKG_SEARCH="dnf search"
FFMPEG_PKG="ffmpeg-free"
GIT_PKG="git"
CURL_PKG="curl"
DISTRO="fedora"
elif command -v yum &>/dev/null; then
PKG_INSTALL="sudo yum install -y -q"
PKG_SEARCH="yum search"
FFMPEG_PKG="ffmpeg"
GIT_PKG="git"
CURL_PKG="curl"
DISTRO="rhel"
elif command -v pacman &>/dev/null; then
PKG_INSTALL="sudo pacman -S --noconfirm --needed"
PKG_SEARCH="pacman -Ss"
FFMPEG_PKG="ffmpeg"
GIT_PKG="git"
CURL_PKG="curl"
DISTRO="arch"
elif command -v apk &>/dev/null; then
PKG_INSTALL="sudo apk add --no-cache"
PKG_SEARCH="apk search"
FFMPEG_PKG="ffmpeg"
GIT_PKG="git"
CURL_PKG="curl"
DISTRO="alpine"
else
err "Could not detect package manager. Supported: apt, dnf, yum, pacman, apk."
exit 1
fi
log "Detected: ${DISTRO} ($(echo ${PKG_INSTALL} | awk '{print $2}'))"
}
if pgrep -f "nextnvr" > /dev/null 2>&1; then
err "Cannot kill existing NextNVR processes. Please stop them manually and retry."
exit 1
fi
log "All instances stopped."
# ── 2. Detect greenfield vs update ──
GREENFIELD=false
if [ ! -d "${DEPLOY_DIR}" ]; then
GREENFIELD=true
log "Greenfield deployment detected."
else
log "Existing installation detected — update mode (config preserved)."
fi
# ── 3. System dependencies ──
detect_pkg_manager() {
if command -v apt-get &>/dev/null; then PKG="sudo apt-get install -y -qq"; DISTRO="debian"
elif command -v dnf &>/dev/null; then PKG="sudo dnf install -y -q"; DISTRO="fedora"
elif command -v yum &>/dev/null; then PKG="sudo yum install -y -q"; DISTRO="rhel"
elif command -v pacman &>/dev/null; then PKG="sudo pacman -S --noconfirm --needed"; DISTRO="arch"
elif command -v apk &>/dev/null; then PKG="sudo apk add --no-cache"; DISTRO="alpine"
else err "Unsupported distro."; exit 1; fi
log "Detected: ${DISTRO}"
}
detect_pkg_manager detect_pkg_manager
# ── 1. Install system dependencies ──
log "Step 1/6: Installing system dependencies..."
if ! command -v ffmpeg &>/dev/null; then if ! command -v ffmpeg &>/dev/null; then
info "Installing ffmpeg (${FFMPEG_PKG})..." info "Installing ffmpeg..."; eval "${PKG} ffmpeg"; log "ffmpeg installed."
eval "${PKG_INSTALL} ${FFMPEG_PKG}" fi
log "ffmpeg installed." if ! command -v git &>/dev/null; then
else info "Installing git..."; eval "${PKG} git"
log "ffmpeg already installed: $(ffmpeg -version 2>&1 | head -1)" fi
if ! command -v curl &>/dev/null; then
info "Installing curl..."; eval "${PKG} curl"
fi
if ! command -v jq &>/dev/null; then
info "Installing jq..."; eval "${PKG} jq"
fi fi
for pkg in git curl; do # ── 4. Time sync ──
pkgvar="$(echo ${pkg} | tr '[:lower:]' '[:upper:]')_PKG"
if ! command -v ${pkg} &>/dev/null; then
info "Installing ${pkg}..."
eval "${PKG_INSTALL} ${!pkgvar}"
fi
done
# ── 1b. Time synchronization ──
log "Checking time sync..."
# Try timedatectl (systemd). Fall back to checking ntp/chrony.
TIME_SYNC_OK=false
if command -v timedatectl &>/dev/null; then if command -v timedatectl &>/dev/null; then
if timedatectl status 2>/dev/null | grep -q "System clock synchronized: yes"; then sudo timedatectl set-ntp true 2>/dev/null || true
TIME_SYNC_OK=true
else
info "Enabling NTP via timedatectl..."
sudo timedatectl set-ntp true 2>/dev/null || true
TIME_SYNC_OK=true
fi
elif systemctl is-active --quiet chronyd 2>/dev/null; then
TIME_SYNC_OK=true
elif systemctl is-active --quiet ntpd 2>/dev/null; then
TIME_SYNC_OK=true
else
warn "No time sync service detected. Trying to install chrony..."
eval "${PKG_INSTALL} chrony 2>/dev/null" || eval "${PKG_INSTALL} chronyd 2>/dev/null" || true
sudo systemctl enable --now chronyd 2>/dev/null || sudo systemctl enable --now chrony 2>/dev/null || true
TIME_SYNC_OK=true
fi fi
log "Time sync: OK | $(date)"
if $TIME_SYNC_OK; then # ── 5. Go ──
log "Time sync: OK"
else
warn "Time sync could not be verified. Timestamps may drift."
fi
log "Local time: $(date)"
TZ=$(timedatectl show --property=Timezone --value 2>/dev/null || cat /etc/timezone 2>/dev/null || echo "unknown")
log "Timezone: ${TZ}"
# ── 2. Install Go (portable — no distro packages) ──
log "Step 2/6: Setting up Go ${GO_VERSION}..."
GO_TAR="go${GO_VERSION}.linux-${GO_ARCH}.tar.gz" GO_TAR="go${GO_VERSION}.linux-${GO_ARCH}.tar.gz"
GO_URL="https://go.dev/dl/${GO_TAR}"
GO_HOME="$HOME/.local/go" GO_HOME="$HOME/.local/go"
if [ ! -f "${GO_HOME}/bin/go" ]; then
if [ -f "${GO_HOME}/bin/go" ]; then info "Installing Go ${GO_VERSION}..."
log "Go already installed: $(${GO_HOME}/bin/go version)" curl -sSL "https://go.dev/dl/${GO_TAR}" -o /tmp/${GO_TAR}
else
info "Downloading Go ${GO_VERSION} for linux/${GO_ARCH}..."
curl -sSL "${GO_URL}" -o /tmp/${GO_TAR}
mkdir -p "${GO_HOME}" mkdir -p "${GO_HOME}"
tar -C "$HOME/.local" -xzf /tmp/${GO_TAR} tar -C "$HOME/.local" -xzf /tmp/${GO_TAR}
rm /tmp/${GO_TAR} rm /tmp/${GO_TAR}
log "Go ${GO_VERSION} installed."
fi fi
export PATH="${GO_HOME}/bin:$PATH" export PATH="${GO_HOME}/bin:$PATH"
export GOPATH="$HOME/go" export GOPATH="$HOME/go"
export GOCACHE="$HOME/.cache/go-build" log "Go: $(${GO_HOME}/bin/go version)"
# ── 3. Clone or update repository ── # ── 6. Clone / pull ──
log "Step 3/6: Fetching NextNVR source..." if $GREENFIELD; then
sudo mkdir -p "${DEPLOY_DIR}"
sudo chown "$(whoami):$(id -gn)" "${DEPLOY_DIR}"
git clone "${GIT_URL}" "${DEPLOY_DIR}"
log "Repository cloned."
else
git -C "${DEPLOY_DIR}" pull origin main
log "Repository updated."
fi
cd "${DEPLOY_DIR}"
# Download go2rtc (required for live streaming and snapshots). # ── 7. go2rtc ──
GO2RTC_VER="1.9.14"
GO2RTC_BIN="${DEPLOY_DIR}/go2rtc" GO2RTC_BIN="${DEPLOY_DIR}/go2rtc"
if [ ! -f "${GO2RTC_BIN}" ]; then if [ ! -f "${GO2RTC_BIN}" ]; then
info "Downloading go2rtc ${GO2RTC_VER}..." info "Downloading go2rtc ${GO2RTC_VER}..."
sudo mkdir -p "${DEPLOY_DIR}" 2>/dev/null || true
curl -sSL "https://github.com/AlexxIT/go2rtc/releases/download/v${GO2RTC_VER}/go2rtc_linux_${GO_ARCH}" -o "${GO2RTC_BIN}" curl -sSL "https://github.com/AlexxIT/go2rtc/releases/download/v${GO2RTC_VER}/go2rtc_linux_${GO_ARCH}" -o "${GO2RTC_BIN}"
chmod +x "${GO2RTC_BIN}" chmod +x "${GO2RTC_BIN}"
log "go2rtc installed." log "go2rtc installed."
fi fi
if [ -d "${DEPLOY_DIR}/.git" ]; then # ── 8. Configuration (greenfield only) ──
info "Repository exists, pulling latest..." CONFIG_FILE="${DEPLOY_DIR}/config.json"
git -C "${DEPLOY_DIR}" pull origin main if $GREENFIELD; then
log "Source updated."
else
info "Cloning repository..."
sudo mkdir -p "${DEPLOY_DIR}"
sudo chown "$(whoami):$(id -gn)" "${DEPLOY_DIR}"
git clone "${GIT_URL}" "${DEPLOY_DIR}"
log "Source cloned."
fi
cd "${DEPLOY_DIR}"
# ── 4. Build ──
log "Step 4/6: Building NextNVR binary..."
go mod tidy
VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
CGO_ENABLED=0 go build \
-ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" \
-o nextnvr .
chmod +x nextnvr
log "Binary built: nextnvr v${VERSION} ($(du -h nextnvr | cut -f1))"
# ── 5. Configuration & directories ──
log "Step 5/6: Configuration..."
# Repo includes a default config.yaml. Check if storage path needs setting.
CURRENT_PATH=$(grep recordings_path "${DEPLOY_DIR}/config.yaml" 2>/dev/null | awk -F'"' '{print $2}')
if [ -z "${CURRENT_PATH}" ] || [ "${CURRENT_PATH}" = "/home/master/nvr_data" ]; then
echo "" echo ""
info "Storage path not set. Where should recordings be stored?" info "Where should recordings be stored?"
info " Examples: /mnt/recordings, /home/user/nvr_data, /media/usb"
if [ -e /dev/tty ]; then if [ -e /dev/tty ]; then
read -p " Path [${HOME}/nvr_data]: " STORAGE_PATH </dev/tty read -p " Storage path [${HOME}/nvr_data]: " STORAGE_PATH </dev/tty
fi fi
STORAGE_PATH="${STORAGE_PATH:-${HOME}/nvr_data}" STORAGE_PATH="${STORAGE_PATH:-${HOME}/nvr_data}"
STORAGE_PATH="${STORAGE_PATH%/}" STORAGE_PATH="${STORAGE_PATH%/}"
echo "" echo ""
# Build config.json from sample.
jq --arg path "${STORAGE_PATH}" \
--arg go2rtc "${GO2RTC_BIN}" \
'.storage.recordings_path = $path | .go2rtc.binary = $go2rtc' \
config.json.sample > "${CONFIG_FILE}"
sudo mkdir -p "${STORAGE_PATH}" sudo mkdir -p "${STORAGE_PATH}"
sudo chown "$(whoami):$(id -gn)" "${STORAGE_PATH}" sudo chown "$(whoami):$(id -gn)" "${STORAGE_PATH}"
sed -i "s|recordings_path:.*|recordings_path: \"${STORAGE_PATH}\"|" "${DEPLOY_DIR}/config.yaml" log "Config created: ${CONFIG_FILE}"
# Set go2rtc binary path. log "Storage path: ${STORAGE_PATH}"
sed -i "s|binary:.*|binary: \"${GO2RTC_BIN}\"|" "${DEPLOY_DIR}/config.yaml"
log "Storage: ${STORAGE_PATH}"
else else
log "Storage: ${CURRENT_PATH}" log "Config preserved: ${CONFIG_FILE}"
sudo mkdir -p "${CURRENT_PATH}" 2>/dev/null || true
fi fi
# ── 6. Install service ── # ── 9. Build ──
log "Step 6/6: Service installation..." log "Building NextNVR..."
go mod tidy 2>/dev/null || true
VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=${VERSION} -X main.BuildTime=${BUILD_TIME}" -o nextnvr .
chmod +x nextnvr
log "Binary: nextnvr v${VERSION} ($(du -h nextnvr | cut -f1))"
RUN_USER="$(whoami)" # ── 10. Systemd service ──
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
HAS_SYSTEMD=false sudo tee "${SERVICE_FILE}" > /dev/null <<EOF
if command -v systemctl &>/dev/null; then
HAS_SYSTEMD=true
fi
if $HAS_SYSTEMD; then
sudo tee "${SERVICE_FILE}" > /dev/null <<EOF
[Unit] [Unit]
Description=NextNVR — IP Camera Recorder Description=NextNVR — IP Camera Recorder
After=network.target After=network.target
@ -268,9 +174,9 @@ Wants=network.target
[Service] [Service]
Type=simple Type=simple
User=${RUN_USER} User=$(whoami)
WorkingDirectory=${DEPLOY_DIR} WorkingDirectory=${DEPLOY_DIR}
ExecStart=${DEPLOY_DIR}/nextnvr --config ${DEPLOY_DIR}/config.yaml ExecStart=${DEPLOY_DIR}/nextnvr --config ${CONFIG_FILE}
Restart=always Restart=always
RestartSec=10 RestartSec=10
LimitNOFILE=65536 LimitNOFILE=65536
@ -281,31 +187,10 @@ StandardError=journal
WantedBy=multi-user.target WantedBy=multi-user.target
EOF EOF
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable "${SERVICE_NAME}" sudo systemctl enable "${SERVICE_NAME}"
sudo systemctl restart "${SERVICE_NAME}" sudo systemctl restart "${SERVICE_NAME}"
sleep 2
sleep 2
echo ""
sudo systemctl status "${SERVICE_NAME}" --no-pager --lines=5 2>/dev/null || true
else
warn "systemd not detected. Running NextNVR in background with nohup."
# Kill any existing instance.
pkill -f "nextnvr --config" 2>/dev/null || true
sleep 1
nohup "${DEPLOY_DIR}/nextnvr" --config "${DEPLOY_DIR}/config.yaml" \
> "${DEPLOY_DIR}/nextnvr.log" 2>&1 &
sleep 2
if pgrep -f "nextnvr --config" > /dev/null; then
log "NextNVR started (PID $(pgrep -f 'nextnvr --config' | head -1))."
info "Logs: tail -f ${DEPLOY_DIR}/nextnvr.log"
else
err "NextNVR failed to start. Check: ${DEPLOY_DIR}/nextnvr.log"
fi
fi
# ── Done ── # ── Done ──
echo "" echo ""
@ -314,18 +199,14 @@ echo -e " ${GREEN}NextNVR deployment complete!${NC}"
echo "============================================" echo "============================================"
echo "" echo ""
info " Web UI: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo 'localhost'):8080" info " Web UI: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo 'localhost'):8080"
info " Config: ${DEPLOY_DIR}/config.yaml" info " Config: ${CONFIG_FILE}"
info " Storage: $(grep recordings_path ${DEPLOY_DIR}/config.yaml 2>/dev/null | awk '{print $2}' | tr -d '"')" info " Storage: $(jq -r .storage.recordings_path ${CONFIG_FILE} 2>/dev/null || echo 'unknown')"
echo "" echo ""
if $HAS_SYSTEMD; then echo " Service:"
echo " Service:" echo " sudo systemctl status ${SERVICE_NAME}"
echo " sudo systemctl status ${SERVICE_NAME}" echo " sudo systemctl restart ${SERVICE_NAME}"
echo " sudo systemctl restart ${SERVICE_NAME}" echo " sudo journalctl -u ${SERVICE_NAME} -f"
echo " sudo journalctl -u ${SERVICE_NAME} -f" echo ""
else
echo " Process:" sudo systemctl status "${SERVICE_NAME}" --no-pager --lines=5 2>/dev/null || true
echo " ps aux | grep nextnvr"
echo " tail -f ${DEPLOY_DIR}/nextnvr.log"
echo " To stop: pkill -f 'nextnvr --config'"
fi
echo "" echo ""

5
go.mod
View file

@ -2,7 +2,4 @@ module github.com/cclohmar/NextNVR
go 1.25.0 go 1.25.0
require ( require golang.org/x/crypto v0.54.0
golang.org/x/crypto v0.54.0
gopkg.in/yaml.v3 v3.0.1
)

4
go.sum
View file

@ -1,6 +1,2 @@
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

10
main.go
View file

@ -136,13 +136,13 @@ func flagConfigPath() string {
} }
// Default: look next to the binary, then /opt/nextnvr. // Default: look next to the binary, then /opt/nextnvr.
if _, err := os.Stat("config.yaml"); err == nil { if _, err := os.Stat("config.json"); err == nil {
return "config.yaml" return "config.json"
} }
if _, err := os.Stat("/opt/nextnvr/config.yaml"); err == nil { if _, err := os.Stat("/opt/nextnvr/config.json"); err == nil {
return "/opt/nextnvr/config.yaml" return "/opt/nextnvr/config.json"
} }
return "config.yaml" return "config.json"
} }
// App holds the application runtime state. // App holds the application runtime state.