chore: Milestone 1 & 2 — core setup, recorder, cleaner, web UI, deployment
This commit is contained in:
parent
5b04c831b5
commit
84bf6d892f
17 changed files with 1746 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
nextnvr
|
||||
26
CHANGELOG.md
Normal file
26
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to NextNVR will be documented in this file.
|
||||
|
||||
## [0.2.0] — 2026-08-05
|
||||
### Added
|
||||
- FFmpeg stream recorder with zero-transcoding (`-c copy`) stream-copy mode
|
||||
- 5-minute MP4 segment output to `/mnt/recordings/{cam_id}/{YYYY-MM-DD}/`
|
||||
- Per-camera goroutines with auto-reconnect on stream failure (5s delay)
|
||||
- Staggered camera startup (500ms between launches)
|
||||
- Storage retention cleaner with configurable `retention_days`
|
||||
- Hourly background purge of expired recordings
|
||||
- Empty directory pruning after cleanup runs
|
||||
|
||||
## [0.1.0] — 2026-08-05
|
||||
### Added
|
||||
- Initial project scaffold and Go module
|
||||
- YAML configuration (`config.yaml`) with 8 camera stubs
|
||||
- Embedded single-binary distribution using Go `embed`
|
||||
- HTTP API endpoints: `/api/cameras`, `/api/config`, `/api/scan`, `/api/status`, `/api/recordings`
|
||||
- Responsive SPA with three tabs: Live Wall, Playback, Settings
|
||||
- 4x2 CSS grid for live camera monitoring
|
||||
- Focus/overlay mode with prev/next navigation and thumbnail strip
|
||||
- ONVIF scan stub (IP range 192.168.1.201–209, skip 204)
|
||||
- go2rtc child process manager with auto-restart
|
||||
- Deployment script (`setup.sh`) with systemd service generation
|
||||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.1.0
|
||||
229
api.go
Normal file
229
api.go
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
// NextNVR v0.1.0 — API handlers
|
||||
// REST endpoints for cameras, configuration, ONVIF discovery, and status.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// handleCameras returns the list of all cameras with runtime status.
|
||||
// GET /api/cameras
|
||||
func (s *Server) handleCameras(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
statuses := make([]CameraStatus, 0, len(s.appConfig.Cameras))
|
||||
for _, cam := range s.appConfig.Cameras {
|
||||
statuses = append(statuses, CameraStatus{
|
||||
CameraConfig: cam,
|
||||
Online: false, // TODO: real stream health check in M2
|
||||
Uptime: "n/a",
|
||||
Streams: 0,
|
||||
})
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: statuses})
|
||||
}
|
||||
|
||||
// handleCameraByID handles single-camera operations.
|
||||
// GET /api/cameras/{id} — get camera details
|
||||
// PUT /api/cameras/{id} — update camera config
|
||||
func (s *Server) handleCameraByID(w http.ResponseWriter, r *http.Request) {
|
||||
camID := strings.TrimPrefix(r.URL.Path, "/api/cameras/")
|
||||
if camID == "" {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "camera ID required"})
|
||||
return
|
||||
}
|
||||
|
||||
cam, idx := s.findCamera(camID)
|
||||
if cam == nil {
|
||||
jsonResponse(w, http.StatusNotFound, APIResponse{Error: "camera not found: " + camID})
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
status := CameraStatus{
|
||||
CameraConfig: *cam,
|
||||
Online: false,
|
||||
Uptime: "n/a",
|
||||
Streams: 0,
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
|
||||
|
||||
case http.MethodPut:
|
||||
var updated CameraConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&updated); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
updated.ID = camID
|
||||
s.appConfig.Cameras[idx] = updated
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: updated})
|
||||
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
// handleConfig handles configuration read/write.
|
||||
// GET /api/config — return current config (without passwords)
|
||||
// POST /api/config — save full config to config.yaml
|
||||
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
// Return config with passwords masked.
|
||||
safe := *s.appConfig
|
||||
for i := range safe.Cameras {
|
||||
if safe.Cameras[i].Password != "" {
|
||||
safe.Cameras[i].Password = "********"
|
||||
}
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: safe})
|
||||
|
||||
case http.MethodPost:
|
||||
var newCfg Config
|
||||
if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil {
|
||||
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Preserve passwords if not provided (masked in UI).
|
||||
for i := range newCfg.Cameras {
|
||||
if newCfg.Cameras[i].Password == "********" || newCfg.Cameras[i].Password == "" {
|
||||
if old := s.findCameraByIP(newCfg.Cameras[i].IP); old != nil {
|
||||
newCfg.Cameras[i].Password = old.Password
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*s.appConfig = newCfg
|
||||
|
||||
if err := SaveConfig(*s.appConfig, "/opt/nextnvr/config.yaml"); err != nil {
|
||||
// Try saving to the app's config path.
|
||||
_ = SaveConfig(*s.appConfig, "config.yaml")
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: "config saved"})
|
||||
|
||||
default:
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
// handleScan triggers ONVIF network discovery.
|
||||
// POST /api/scan
|
||||
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: M3 — real ONVIF WS-Discovery.
|
||||
// For now, return a stub response with the expected IP range.
|
||||
type DiscoveredCamera struct {
|
||||
IP string `json:"ip"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
RTSPMain string `json:"rtsp_main"`
|
||||
RTSPSub string `json:"rtsp_sub"`
|
||||
Found bool `json:"found"`
|
||||
}
|
||||
|
||||
results := make([]DiscoveredCamera, 0)
|
||||
for ip := 201; ip <= 209; ip++ {
|
||||
if ip == 204 {
|
||||
continue
|
||||
}
|
||||
ipStr := "192.168.1." + itoa(ip)
|
||||
results = append(results, DiscoveredCamera{
|
||||
IP: ipStr,
|
||||
Manufacturer: "Unknown",
|
||||
Model: "ONVIF Camera",
|
||||
RTSPMain: "rtsp://" + ipStr + ":554/Streaming/Channels/101",
|
||||
RTSPSub: "rtsp://" + ipStr + ":554/Streaming/Channels/102",
|
||||
Found: false, // requires real ONVIF probe in M3
|
||||
})
|
||||
}
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results})
|
||||
}
|
||||
|
||||
// handleStatus returns server health and runtime information.
|
||||
// GET /api/status
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
status := map[string]interface{}{
|
||||
"version": Version,
|
||||
"cameras_total": len(s.appConfig.Cameras),
|
||||
"cameras_active": 0, // TODO: real counts in M2
|
||||
"uptime": "n/a",
|
||||
}
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status})
|
||||
}
|
||||
|
||||
// handleRecordings returns available recordings for playback.
|
||||
// GET /api/recordings?cam={id}&date={YYYY-MM-DD}
|
||||
func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
camID := r.URL.Query().Get("cam")
|
||||
date := r.URL.Query().Get("date")
|
||||
|
||||
// TODO: M2/M3 — scan filesystem for actual .mp4 files.
|
||||
type Clip struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
_ = camID
|
||||
_ = date
|
||||
clips := make([]Clip, 0)
|
||||
|
||||
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips})
|
||||
}
|
||||
|
||||
// findCamera locates a camera by ID.
|
||||
func (s *Server) findCamera(id string) (*CameraConfig, int) {
|
||||
for i, cam := range s.appConfig.Cameras {
|
||||
if cam.ID == id {
|
||||
return &s.appConfig.Cameras[i], i
|
||||
}
|
||||
}
|
||||
return nil, -1
|
||||
}
|
||||
|
||||
// findCameraByIP locates a camera by IP address.
|
||||
func (s *Server) findCameraByIP(ip string) *CameraConfig {
|
||||
for i := range s.appConfig.Cameras {
|
||||
if s.appConfig.Cameras[i].IP == ip {
|
||||
return &s.appConfig.Cameras[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// itoa is a simple int-to-string helper (avoids importing strconv).
|
||||
func itoa(i int) string {
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
digits := ""
|
||||
for n := i; n > 0; n /= 10 {
|
||||
digits = string(rune('0'+n%10)) + digits
|
||||
}
|
||||
return digits
|
||||
}
|
||||
130
cleaner.go
Normal file
130
cleaner.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// NextNVR v0.2.0 — Storage retention cleaner
|
||||
// Background ticker that purges recordings older than retention_days.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cleaner manages the periodic purge of old recordings.
|
||||
type Cleaner struct {
|
||||
config StorageConfig
|
||||
stopCh chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewCleaner creates a new retention cleaner.
|
||||
func NewCleaner(cfg StorageConfig) *Cleaner {
|
||||
if cfg.CleanupIntervalMins <= 0 {
|
||||
cfg.CleanupIntervalMins = 60
|
||||
}
|
||||
return &Cleaner{
|
||||
config: cfg,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the periodic cleanup loop.
|
||||
func (c *Cleaner) Start() {
|
||||
c.running = true
|
||||
interval := time.Duration(c.config.CleanupIntervalMins) * time.Minute
|
||||
|
||||
log.Printf("cleaner: starting — retention=%d days, interval=%v, path=%s",
|
||||
c.config.RetentionDays, interval, c.config.RecordingsPath)
|
||||
|
||||
// Run immediately on startup.
|
||||
go c.purge()
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.stopCh:
|
||||
c.running = false
|
||||
log.Println("cleaner: stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
go c.purge()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop signals the cleaner to shutdown.
|
||||
func (c *Cleaner) Stop() {
|
||||
close(c.stopCh)
|
||||
}
|
||||
|
||||
// purge walks the recordings directory and deletes files older than retention_days.
|
||||
func (c *Cleaner) purge() {
|
||||
cutoff := time.Now().Add(-time.Duration(c.config.RetentionDays) * 24 * time.Hour)
|
||||
deleted := 0
|
||||
var freedBytes int64
|
||||
|
||||
err := filepath.Walk(c.config.RecordingsPath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // skip unreadable files
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(info.Name()), ".mp4") {
|
||||
return nil
|
||||
}
|
||||
if info.ModTime().After(cutoff) {
|
||||
return nil
|
||||
}
|
||||
|
||||
size := info.Size()
|
||||
if err := os.Remove(path); err != nil {
|
||||
log.Printf("cleaner: failed to remove %s: %v", path, err)
|
||||
return nil
|
||||
}
|
||||
deleted++
|
||||
freedBytes += size
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("cleaner: walk error: %v", err)
|
||||
}
|
||||
|
||||
// Prune empty directories.
|
||||
c.pruneEmptyDirs(c.config.RecordingsPath)
|
||||
|
||||
if deleted > 0 {
|
||||
log.Printf("cleaner: removed %d files, freed %s", deleted, formatBytes(freedBytes))
|
||||
}
|
||||
}
|
||||
|
||||
// pruneEmptyDirs removes empty date directories within the recordings tree.
|
||||
func (c *Cleaner) pruneEmptyDirs(root string) {
|
||||
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || !info.IsDir() || path == root {
|
||||
return nil
|
||||
}
|
||||
entries, _ := os.ReadDir(path)
|
||||
if len(entries) == 0 {
|
||||
os.Remove(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// formatBytes returns a human-readable byte count.
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return itoa(int(bytes)) + " B"
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return itoa(int(float64(bytes)/float64(div)*10)/10) + " " + string("KMGTPE"[exp]) + "B"
|
||||
}
|
||||
132
config.go
Normal file
132
config.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// NextNVR v0.1.0 — Configuration management
|
||||
// Handles config.yaml parsing with sensible defaults
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config represents the top-level application configuration.
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
Cameras []CameraConfig `yaml:"cameras"`
|
||||
Go2RTC Go2RTCConfig `yaml:"go2rtc"`
|
||||
}
|
||||
|
||||
// ServerConfig holds HTTP server settings.
|
||||
type ServerConfig struct {
|
||||
Port string `yaml:"port"`
|
||||
BindHost string `yaml:"bind_host"`
|
||||
}
|
||||
|
||||
// StorageConfig holds recording and retention settings.
|
||||
type StorageConfig struct {
|
||||
RecordingsPath string `yaml:"recordings_path"`
|
||||
RetentionDays int `yaml:"retention_days"`
|
||||
CleanupIntervalMins int `yaml:"cleanup_interval_mins"`
|
||||
}
|
||||
|
||||
// CameraConfig represents a single camera's configuration.
|
||||
type CameraConfig struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
IP string `yaml:"ip"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
ONVIFPort int `yaml:"onvif_port"`
|
||||
RTSPMain string `yaml:"rtsp_main"`
|
||||
RTSPSub string `yaml:"rtsp_sub"`
|
||||
Description string `yaml:"description"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Record bool `yaml:"record"`
|
||||
}
|
||||
|
||||
// Go2RTCConfig holds go2rtc child process settings.
|
||||
type Go2RTCConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Port string `yaml:"port"`
|
||||
Binary string `yaml:"binary"`
|
||||
}
|
||||
|
||||
// APIResponse wraps all JSON API responses.
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CameraStatus extends CameraConfig with runtime information.
|
||||
type CameraStatus struct {
|
||||
CameraConfig
|
||||
Online bool `json:"online"`
|
||||
Uptime string `json:"uptime"`
|
||||
Streams int `json:"streams"`
|
||||
}
|
||||
|
||||
// DefaultConfig returns a Config with sensible defaults.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Server: ServerConfig{
|
||||
Port: ":8080",
|
||||
BindHost: "127.0.0.1",
|
||||
},
|
||||
Storage: StorageConfig{
|
||||
RecordingsPath: "/mnt/recordings",
|
||||
RetentionDays: 18,
|
||||
CleanupIntervalMins: 60,
|
||||
},
|
||||
Go2RTC: Go2RTCConfig{
|
||||
Enabled: true,
|
||||
Port: ":1984",
|
||||
Binary: "go2rtc",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadConfig reads and parses a YAML configuration file.
|
||||
// If the file does not exist, it returns DefaultConfig().
|
||||
func LoadConfig(path string) (Config, error) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, fmt.Errorf("config file not found: %s", path)
|
||||
}
|
||||
return cfg, fmt.Errorf("reading config: %w", err)
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// SaveConfig writes the configuration to a YAML file.
|
||||
func SaveConfig(cfg Config, path string) error {
|
||||
data, err := yaml.Marshal(&cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling config: %w", err)
|
||||
}
|
||||
|
||||
header := []byte("# NextNVR Configuration\n# Generated by NextNVR setup\n\n")
|
||||
out := append(header, data...)
|
||||
|
||||
if err := os.WriteFile(path, out, 0644); err != nil {
|
||||
return fmt.Errorf("writing config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
113
config.yaml
Normal file
113
config.yaml
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# NextNVR Configuration
|
||||
# Generated by NextNVR setup
|
||||
|
||||
server:
|
||||
port: ":8080"
|
||||
bind_host: "127.0.0.1"
|
||||
|
||||
storage:
|
||||
recordings_path: "/mnt/recordings"
|
||||
retention_days: 18
|
||||
cleanup_interval_mins: 60
|
||||
|
||||
go2rtc:
|
||||
enabled: true
|
||||
port: ":1984"
|
||||
binary: "go2rtc"
|
||||
|
||||
cameras:
|
||||
- id: "cam_201"
|
||||
name: "Camera 201"
|
||||
ip: "192.168.1.201"
|
||||
username: "admin"
|
||||
password: ""
|
||||
onvif_port: 80
|
||||
rtsp_main: ""
|
||||
rtsp_sub: ""
|
||||
description: ""
|
||||
enabled: true
|
||||
record: true
|
||||
|
||||
- id: "cam_202"
|
||||
name: "Camera 202"
|
||||
ip: "192.168.1.202"
|
||||
username: "admin"
|
||||
password: ""
|
||||
onvif_port: 80
|
||||
rtsp_main: ""
|
||||
rtsp_sub: ""
|
||||
description: ""
|
||||
enabled: true
|
||||
record: true
|
||||
|
||||
- id: "cam_203"
|
||||
name: "Camera 203"
|
||||
ip: "192.168.1.203"
|
||||
username: "admin"
|
||||
password: ""
|
||||
onvif_port: 80
|
||||
rtsp_main: ""
|
||||
rtsp_sub: ""
|
||||
description: ""
|
||||
enabled: true
|
||||
record: true
|
||||
|
||||
- id: "cam_205"
|
||||
name: "Camera 205"
|
||||
ip: "192.168.1.205"
|
||||
username: "admin"
|
||||
password: ""
|
||||
onvif_port: 80
|
||||
rtsp_main: ""
|
||||
rtsp_sub: ""
|
||||
description: ""
|
||||
enabled: true
|
||||
record: true
|
||||
|
||||
- id: "cam_206"
|
||||
name: "Camera 206"
|
||||
ip: "192.168.1.206"
|
||||
username: "admin"
|
||||
password: ""
|
||||
onvif_port: 80
|
||||
rtsp_main: ""
|
||||
rtsp_sub: ""
|
||||
description: ""
|
||||
enabled: true
|
||||
record: true
|
||||
|
||||
- id: "cam_207"
|
||||
name: "Camera 207"
|
||||
ip: "192.168.1.207"
|
||||
username: "admin"
|
||||
password: ""
|
||||
onvif_port: 80
|
||||
rtsp_main: ""
|
||||
rtsp_sub: ""
|
||||
description: ""
|
||||
enabled: true
|
||||
record: true
|
||||
|
||||
- id: "cam_208"
|
||||
name: "Camera 208"
|
||||
ip: "192.168.1.208"
|
||||
username: "admin"
|
||||
password: ""
|
||||
onvif_port: 80
|
||||
rtsp_main: ""
|
||||
rtsp_sub: ""
|
||||
description: ""
|
||||
enabled: true
|
||||
record: true
|
||||
|
||||
- id: "cam_209"
|
||||
name: "Camera 209"
|
||||
ip: "192.168.1.209"
|
||||
username: "admin"
|
||||
password: ""
|
||||
onvif_port: 80
|
||||
rtsp_main: ""
|
||||
rtsp_sub: ""
|
||||
description: ""
|
||||
enabled: true
|
||||
record: true
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module github.com/cclohmar/NextNVR
|
||||
|
||||
go 1.24.5
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
4
go.sum
Normal file
4
go.sum
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
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=
|
||||
143
go2rtc.go
Normal file
143
go2rtc.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// NextNVR v0.1.0 — go2rtc child process management
|
||||
// Launches and monitors go2rtc for WebRTC stream conversion.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Go2RTCManager handles the go2rtc child process lifecycle.
|
||||
type Go2RTCManager struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
config Go2RTCConfig
|
||||
running bool
|
||||
}
|
||||
|
||||
// NewGo2RTCManager creates a new go2rtc process manager.
|
||||
func NewGo2RTCManager(cfg Go2RTCConfig) *Go2RTCManager {
|
||||
return &Go2RTCManager{
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the go2rtc process and generates its configuration.
|
||||
func (g *Go2RTCManager) Start(cameras []CameraConfig) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if !g.config.Enabled {
|
||||
log.Println("go2rtc: disabled in config, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate go2rtc.yaml config from camera list.
|
||||
if err := g.writeConfig(cameras); err != nil {
|
||||
return fmt.Errorf("go2rtc config: %w", err)
|
||||
}
|
||||
|
||||
// Launch go2rtc as a child process.
|
||||
g.cmd = exec.Command(g.config.Binary, "-config", "go2rtc.yaml")
|
||||
g.cmd.Stdout = os.Stdout
|
||||
g.cmd.Stderr = os.Stderr
|
||||
|
||||
if err := g.cmd.Start(); err != nil {
|
||||
return fmt.Errorf("starting go2rtc: %w", err)
|
||||
}
|
||||
|
||||
g.running = true
|
||||
log.Printf("go2rtc: started on %s (PID %d)", g.config.Port, g.cmd.Process.Pid)
|
||||
|
||||
// Monitor the process in the background.
|
||||
go g.monitor()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop terminates the go2rtc process.
|
||||
func (g *Go2RTCManager) Stop() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
if g.cmd != nil && g.cmd.Process != nil {
|
||||
log.Println("go2rtc: stopping...")
|
||||
g.cmd.Process.Signal(os.Interrupt)
|
||||
time.Sleep(2 * time.Second)
|
||||
g.cmd.Process.Kill()
|
||||
g.running = false
|
||||
log.Println("go2rtc: stopped")
|
||||
}
|
||||
}
|
||||
|
||||
// monitor watches the go2rtc process and restarts it on crash.
|
||||
func (g *Go2RTCManager) monitor() {
|
||||
for {
|
||||
if g.cmd == nil {
|
||||
return
|
||||
}
|
||||
err := g.cmd.Wait()
|
||||
g.mu.Lock()
|
||||
wasRunning := g.running
|
||||
g.mu.Unlock()
|
||||
|
||||
if !wasRunning {
|
||||
return // intentional shutdown
|
||||
}
|
||||
|
||||
log.Printf("go2rtc: process exited (%v) — restarting in 5s", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
g.mu.Lock()
|
||||
if g.running {
|
||||
g.cmd = exec.Command(g.config.Binary, "-config", "go2rtc.yaml")
|
||||
g.cmd.Stdout = os.Stdout
|
||||
g.cmd.Stderr = os.Stderr
|
||||
if startErr := g.cmd.Start(); startErr != nil {
|
||||
log.Printf("go2rtc: restart failed: %v", startErr)
|
||||
g.running = false
|
||||
} else {
|
||||
log.Printf("go2rtc: restarted (PID %d)", g.cmd.Process.Pid)
|
||||
}
|
||||
}
|
||||
g.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// writeConfig generates the go2rtc.yaml configuration from camera definitions.
|
||||
func (g *Go2RTCManager) writeConfig(cameras []CameraConfig) error {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("# go2rtc configuration — generated by NextNVR\n")
|
||||
sb.WriteString("api:\n")
|
||||
sb.WriteString(" listen: \"" + g.config.Port + "\"\n\n")
|
||||
sb.WriteString("streams:\n")
|
||||
|
||||
for _, cam := range cameras {
|
||||
if !cam.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
// Use sub stream for live grid, main stream for full quality.
|
||||
subURL := cam.RTSPSub
|
||||
if subURL == "" {
|
||||
subURL = fmt.Sprintf("rtsp://%s:%s@%s:554/Streaming/Channels/102",
|
||||
cam.Username, cam.Password, cam.IP)
|
||||
}
|
||||
|
||||
mainURL := cam.RTSPMain
|
||||
if mainURL == "" {
|
||||
mainURL = fmt.Sprintf("rtsp://%s:%s@%s:554/Streaming/Channels/101",
|
||||
cam.Username, cam.Password, cam.IP)
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" %s_sub: %s\n", cam.ID, subURL))
|
||||
sb.WriteString(fmt.Sprintf(" %s_main: %s\n", cam.ID, mainURL))
|
||||
}
|
||||
|
||||
return os.WriteFile("go2rtc.yaml", []byte(sb.String()), 0644)
|
||||
}
|
||||
112
main.go
Normal file
112
main.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// NextNVR v0.1.0 — Main entry point
|
||||
// Lightweight NVR application for recording and monitoring IP cameras.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
var (
|
||||
// Version is set at build time via ldflags.
|
||||
Version = "0.1.0"
|
||||
|
||||
// BuildTime is set at build time via ldflags.
|
||||
BuildTime = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
log.Printf("NextNVR v%s (built %s)", Version, BuildTime)
|
||||
|
||||
// Parse command-line flags.
|
||||
configPath := flagConfigPath()
|
||||
|
||||
// Load or create configuration.
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
log.Printf("No existing config found at %s — starting in setup mode", configPath)
|
||||
cfg = DefaultConfig()
|
||||
// Don't auto-save; wait for the user to complete setup via the web UI.
|
||||
}
|
||||
|
||||
log.Printf("Server: %s", cfg.Server.Port)
|
||||
log.Printf("Storage: %s (retention: %d days)", cfg.Storage.RecordingsPath, cfg.Storage.RetentionDays)
|
||||
log.Printf("Cameras configured: %d", len(cfg.Cameras))
|
||||
|
||||
if cfg.Go2RTC.Enabled {
|
||||
log.Printf("go2rtc: enabled on port %s (binary: %s)", cfg.Go2RTC.Port, cfg.Go2RTC.Binary)
|
||||
}
|
||||
|
||||
// Signal handling for graceful shutdown.
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// Start the HTTP server (blocks).
|
||||
errCh := make(chan error, 1)
|
||||
app := &App{Config: cfg, ConfigPath: configPath}
|
||||
go func() {
|
||||
errCh <- app.StartServer()
|
||||
}()
|
||||
|
||||
select {
|
||||
case sig := <-sigCh:
|
||||
log.Printf("Received signal: %v — shutting down", sig)
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
log.Printf("Server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
app.Shutdown()
|
||||
log.Println("NextNVR stopped.")
|
||||
}
|
||||
|
||||
// flagConfigPath resolves the config file path from CLI flags.
|
||||
func flagConfigPath() string {
|
||||
// Look for --config flag in os.Args.
|
||||
for i, arg := range os.Args[1:] {
|
||||
if arg == "--config" && i+1 < len(os.Args[1:]) {
|
||||
return os.Args[i+2] // +2 because Args[0] is binary name, Args[1:] starts at index 1
|
||||
}
|
||||
if len(arg) > 9 && arg[:9] == "--config=" {
|
||||
return arg[9:]
|
||||
}
|
||||
}
|
||||
|
||||
// Default: look next to the binary, then /opt/nextnvr.
|
||||
if _, err := os.Stat("config.yaml"); err == nil {
|
||||
return "config.yaml"
|
||||
}
|
||||
if _, err := os.Stat("/opt/nextnvr/config.yaml"); err == nil {
|
||||
return "/opt/nextnvr/config.yaml"
|
||||
}
|
||||
return "config.yaml"
|
||||
}
|
||||
|
||||
// App holds the application runtime state.
|
||||
type App struct {
|
||||
Config Config
|
||||
ConfigPath string
|
||||
server *Server
|
||||
}
|
||||
|
||||
// StartServer initializes and starts the HTTP server.
|
||||
func (a *App) StartServer() error {
|
||||
srv, err := NewServer(a.Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating server: %w", err)
|
||||
}
|
||||
a.server = srv
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
|
||||
// Shutdown performs a graceful shutdown of all services.
|
||||
func (a *App) Shutdown() {
|
||||
if a.server != nil {
|
||||
a.server.Close()
|
||||
}
|
||||
}
|
||||
278
public/app.js
Normal file
278
public/app.js
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
// NextNVR v0.1.0 — Single Page Application
|
||||
// Tabbed UI: Live Wall (4×2 grid + focus), Playback browser, Settings/Onboarding
|
||||
|
||||
const API = '/api';
|
||||
|
||||
// ── App State ──
|
||||
let cameras = [];
|
||||
let config = null;
|
||||
|
||||
// ── Init ──
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
setupTabs();
|
||||
await loadStatus();
|
||||
await loadCameras();
|
||||
renderLiveGrid();
|
||||
renderPlaybackCameras();
|
||||
renderCameraCards();
|
||||
});
|
||||
|
||||
// ── Tabs ──
|
||||
function setupTabs() {
|
||||
document.querySelectorAll('.tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Status ──
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const r = await fetch(API + '/status');
|
||||
const j = await r.json();
|
||||
const dot = document.getElementById('status-dot');
|
||||
dot.className = 'status ' + (j.success ? 'online' : 'offline');
|
||||
} catch(e) {
|
||||
document.getElementById('status-dot').className = 'status offline';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cameras ──
|
||||
async function loadCameras() {
|
||||
try {
|
||||
const r = await fetch(API + '/cameras');
|
||||
const j = await r.json();
|
||||
if (j.success) cameras = j.data;
|
||||
} catch(e) { cameras = []; }
|
||||
}
|
||||
|
||||
// ── Live Grid ──
|
||||
function renderLiveGrid() {
|
||||
const grid = document.getElementById('grid');
|
||||
grid.innerHTML = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const cam = cameras[i];
|
||||
const tile = document.createElement('div');
|
||||
tile.className = 'grid-tile' + (cam && cam.enabled ? '' : ' offline');
|
||||
tile.dataset.camId = cam ? cam.id : '';
|
||||
|
||||
if (cam) {
|
||||
tile.innerHTML = `
|
||||
<span class="tile-status ${cam.online ? 'online' : 'offline'}"></span>
|
||||
<span class="tile-label">${cam.name}</span>
|
||||
`;
|
||||
tile.addEventListener('click', () => openFocus(cam));
|
||||
} else {
|
||||
tile.innerHTML = '<span class="tile-placeholder">No Camera</span>';
|
||||
}
|
||||
grid.appendChild(tile);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Focus Overlay ──
|
||||
let focusIdx = -1;
|
||||
function openFocus(cam) {
|
||||
focusIdx = cameras.indexOf(cam);
|
||||
const overlay = document.getElementById('focus-overlay');
|
||||
overlay.classList.remove('hidden');
|
||||
updateFocus();
|
||||
renderThumbs();
|
||||
}
|
||||
function updateFocus() {
|
||||
if (focusIdx < 0 || focusIdx >= cameras.length) return;
|
||||
const cam = cameras[focusIdx];
|
||||
document.getElementById('focus-title').textContent = cam.name;
|
||||
document.getElementById('focus-time').textContent = new Date().toLocaleTimeString() + ' live';
|
||||
// TODO: M3 — connect to go2rtc WebRTC stream
|
||||
const vid = document.getElementById('focus-video');
|
||||
vid.src = '';
|
||||
vid.poster = ''; // placeholder until go2rtc is wired up
|
||||
}
|
||||
function renderThumbs() {
|
||||
const strip = document.getElementById('focus-thumbs');
|
||||
strip.innerHTML = '';
|
||||
cameras.forEach((cam, i) => {
|
||||
const thumb = document.createElement('div');
|
||||
thumb.className = 'thumb' + (i === focusIdx ? ' active' : '');
|
||||
thumb.textContent = cam.name;
|
||||
thumb.title = cam.name;
|
||||
thumb.addEventListener('click', () => { focusIdx = i; updateFocus(); renderThumbs(); });
|
||||
strip.appendChild(thumb);
|
||||
});
|
||||
}
|
||||
document.getElementById('focus-close').addEventListener('click', () => {
|
||||
document.getElementById('focus-overlay').classList.add('hidden');
|
||||
});
|
||||
document.getElementById('focus-prev').addEventListener('click', () => {
|
||||
if (focusIdx > 0) { focusIdx--; updateFocus(); renderThumbs(); }
|
||||
});
|
||||
document.getElementById('focus-next').addEventListener('click', () => {
|
||||
if (focusIdx < cameras.length - 1) { focusIdx++; updateFocus(); renderThumbs(); }
|
||||
});
|
||||
document.addEventListener('keydown', e => {
|
||||
if (document.getElementById('focus-overlay').classList.contains('hidden')) return;
|
||||
if (e.key === 'Escape') document.getElementById('focus-overlay').classList.add('hidden');
|
||||
if (e.key === 'ArrowLeft' && focusIdx > 0) { focusIdx--; updateFocus(); renderThumbs(); }
|
||||
if (e.key === 'ArrowRight' && focusIdx < cameras.length - 1) { focusIdx++; updateFocus(); renderThumbs(); }
|
||||
});
|
||||
|
||||
// ── Playback ──
|
||||
function renderPlaybackCameras() {
|
||||
const sel = document.getElementById('pb-camera');
|
||||
sel.innerHTML = '<option value="">— Select Camera —</option>';
|
||||
cameras.forEach(c => {
|
||||
sel.innerHTML += `<option value="${c.id}">${c.name}</option>`;
|
||||
});
|
||||
}
|
||||
document.getElementById('pb-load').addEventListener('click', async () => {
|
||||
const cam = document.getElementById('pb-camera').value;
|
||||
const date = document.getElementById('pb-date').value;
|
||||
if (!cam || !date) return;
|
||||
try {
|
||||
const r = await fetch(API + '/recordings?cam=' + cam + '&date=' + date);
|
||||
const j = await r.json();
|
||||
renderClips(j.data || []);
|
||||
} catch(e) { renderClips([]); }
|
||||
});
|
||||
function renderClips(clips) {
|
||||
const grid = document.getElementById('pb-clips');
|
||||
if (clips.length === 0) { grid.innerHTML = '<p style="color:var(--text-muted)">No recordings found.</p>'; return; }
|
||||
grid.innerHTML = clips.map(c => `
|
||||
<div class="clip-card" onclick="playClip('${c.path}')">
|
||||
<div>🎬 ${c.name}</div>
|
||||
<div class="clip-time">${c.time}</div>
|
||||
<div class="clip-size">${formatSize(c.size)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
function playClip(path) {
|
||||
document.getElementById('pb-player').classList.remove('hidden');
|
||||
document.getElementById('pb-video').src = '/recordings/' + path;
|
||||
}
|
||||
document.getElementById('pb-close').addEventListener('click', () => {
|
||||
document.getElementById('pb-player').classList.add('hidden');
|
||||
document.getElementById('pb-video').src = '';
|
||||
});
|
||||
|
||||
// ── Settings / Onboarding ──
|
||||
async function renderCameraCards() {
|
||||
const container = document.getElementById('camera-cards');
|
||||
if (cameras.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted)">
|
||||
<p style="font-size:18px">🎥 Welcome to NextNVR</p>
|
||||
<p style="margin-top:8px">No cameras configured yet. Scan your network to get started.</p>
|
||||
<button class="btn-primary" style="margin-top:16px" onclick="document.getElementById('btn-scan').click()">
|
||||
🔍 Scan Network (ONVIF)
|
||||
</button>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
container.innerHTML = cameras.map(c => `
|
||||
<div class="camera-card" data-id="${c.id}">
|
||||
<h3>📷 ${c.name || 'Unnamed'} <span style="font-size:11px;color:var(--text-muted)">${c.ip}</span></h3>
|
||||
<label>Name <input type="text" value="${escAttr(c.name)}" data-field="name"></label>
|
||||
<label>Description <textarea data-field="description">${escHtml(c.description)}</textarea></label>
|
||||
<label>RTSP Main <input type="text" value="${escAttr(c.rtsp_main)}" data-field="rtsp_main"></label>
|
||||
<label>RTSP Sub <input type="text" value="${escAttr(c.rtsp_sub)}" data-field="rtsp_sub"></label>
|
||||
<label>Username <input type="text" value="${escAttr(c.username)}" data-field="username"></label>
|
||||
<label>Password <input type="password" value="${escAttr(c.password)}" data-field="password"></label>
|
||||
<div class="row">
|
||||
<label>Enabled <input type="checkbox" ${c.enabled ? 'checked' : ''} data-field="enabled" style="width:auto"></label>
|
||||
<label>Record <input type="checkbox" ${c.record ? 'checked' : ''} data-field="record" style="width:auto"></label>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ── Settings: Scan ──
|
||||
document.getElementById('btn-scan').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('btn-scan');
|
||||
const status = document.getElementById('settings-status');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Scanning...';
|
||||
status.textContent = 'Probing 192.168.1.201–209...';
|
||||
try {
|
||||
const r = await fetch(API + '/scan', { method: 'POST' });
|
||||
const j = await r.json();
|
||||
if (j.success) {
|
||||
status.textContent = `Found ${j.data.length} devices. Fill in names and save.`;
|
||||
// Pre-fill camera cards with discovered data.
|
||||
cameras = j.data.map((d, i) => ({
|
||||
id: 'cam_' + d.ip.split('.').pop(),
|
||||
name: d.found ? d.manufacturer + ' ' + d.model : 'Camera ' + (201 + i),
|
||||
ip: d.ip,
|
||||
username: 'admin', password: '',
|
||||
onvif_port: 80,
|
||||
rtsp_main: d.rtsp_main || '',
|
||||
rtsp_sub: d.rtsp_sub || '',
|
||||
description: '',
|
||||
enabled: true, record: true,
|
||||
online: d.found
|
||||
}));
|
||||
renderCameraCards();
|
||||
renderLiveGrid();
|
||||
renderPlaybackCameras();
|
||||
}
|
||||
} catch(e) {
|
||||
status.textContent = 'Scan failed: ' + e.message;
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔍 Scan Network (ONVIF)';
|
||||
});
|
||||
|
||||
// ── Settings: Save ──
|
||||
document.getElementById('btn-save').addEventListener('click', async () => {
|
||||
const status = document.getElementById('settings-status');
|
||||
// Collect camera data from DOM.
|
||||
const cards = document.querySelectorAll('.camera-card');
|
||||
const updatedCameras = [];
|
||||
cards.forEach(card => {
|
||||
const c = {};
|
||||
card.querySelectorAll('[data-field]').forEach(el => {
|
||||
const field = el.dataset.field;
|
||||
if (el.type === 'checkbox') c[field] = el.checked;
|
||||
else c[field] = el.value;
|
||||
});
|
||||
updatedCameras.push(c);
|
||||
});
|
||||
|
||||
try {
|
||||
// Fetch current config, then post updated version.
|
||||
const cr = await fetch(API + '/config');
|
||||
const cj = await cr.json();
|
||||
const cfg = cj.data || {};
|
||||
cfg.cameras = updatedCameras;
|
||||
|
||||
const r = await fetch(API + '/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg)
|
||||
});
|
||||
const j = await r.json();
|
||||
if (j.success) {
|
||||
status.textContent = '✅ Configuration saved! Restart may be required.';
|
||||
status.style.color = 'var(--green)';
|
||||
} else {
|
||||
status.textContent = '❌ Save failed: ' + j.error;
|
||||
status.style.color = 'var(--red)';
|
||||
}
|
||||
} catch(e) {
|
||||
status.textContent = '❌ Error: ' + e.message;
|
||||
status.style.color = 'var(--red)';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Helpers ──
|
||||
function escAttr(s) { return (s || '').replace(/&/g,'&').replace(/"/g,'"'); }
|
||||
function escHtml(s) { return (s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
function formatSize(bytes) {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const u = ['B','KB','MB','GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + u[i];
|
||||
}
|
||||
65
public/index.html
Normal file
65
public/index.html
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NextNVR — Casa Alba Mindelo</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<h1>🎥 NextNVR</h1>
|
||||
<nav id="tabs">
|
||||
<button data-tab="live" class="tab active">Live Wall</button>
|
||||
<button data-tab="playback" class="tab">Playback</button>
|
||||
<button data-tab="settings" class="tab">Settings</button>
|
||||
</nav>
|
||||
<span id="status-dot" class="status offline" title="Server status">●</span>
|
||||
</header>
|
||||
|
||||
<main id="content">
|
||||
<!-- Live Wall: 4×2 grid + focus overlay -->
|
||||
<section id="tab-live" class="tab-panel active">
|
||||
<div id="grid" class="grid-4x2"></div>
|
||||
<div id="focus-overlay" class="focus-overlay hidden">
|
||||
<div class="focus-toolbar">
|
||||
<button id="focus-prev">◀</button>
|
||||
<span id="focus-title">Camera</span>
|
||||
<span id="focus-time" class="focus-time">00:00 live</span>
|
||||
<button id="focus-close">✕</button>
|
||||
<button id="focus-next">▶</button>
|
||||
</div>
|
||||
<video id="focus-video" autoplay muted playsinline></video>
|
||||
<div id="focus-thumbs" class="thumb-strip"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Playback: clip browser -->
|
||||
<section id="tab-playback" class="tab-panel">
|
||||
<div class="playback-controls">
|
||||
<select id="pb-camera"><option value="">— Select Camera —</option></select>
|
||||
<input type="date" id="pb-date">
|
||||
<button id="pb-load">Load Clips</button>
|
||||
</div>
|
||||
<div id="pb-clips" class="clip-grid"></div>
|
||||
<div id="pb-player" class="clip-player hidden">
|
||||
<video id="pb-video" controls></video>
|
||||
<button id="pb-close">✕ Close</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Settings: camera configuration & onboarding -->
|
||||
<section id="tab-settings" class="tab-panel">
|
||||
<div class="settings-toolbar">
|
||||
<button id="btn-scan" class="btn-primary">🔍 Scan Network (ONVIF)</button>
|
||||
<button id="btn-save" class="btn-success">💾 Save Configuration</button>
|
||||
<span id="settings-status"></span>
|
||||
</div>
|
||||
<div id="camera-cards"></div>
|
||||
<div id="scan-results" class="scan-results hidden"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
180
public/style.css
Normal file
180
public/style.css
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/* NextNVR v0.1.0 — Minimal responsive CSS */
|
||||
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--surface: #161b22;
|
||||
--border: #30363d;
|
||||
--text: #c9d1d9;
|
||||
--text-muted: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--green: #3fb950;
|
||||
--red: #f85149;
|
||||
--orange: #d2991d;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Top Bar ── */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 10px 20px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
}
|
||||
.topbar h1 { font-size: 18px; font-weight: 600; }
|
||||
.tab {
|
||||
background: none; border: none; color: var(--text-muted);
|
||||
padding: 6px 14px; cursor: pointer; border-radius: var(--radius);
|
||||
font-size: 14px; transition: all .15s;
|
||||
}
|
||||
.tab:hover { color: var(--text); background: var(--border); }
|
||||
.tab.active { color: #fff; background: var(--accent); }
|
||||
.status { margin-left: auto; font-size: 22px; }
|
||||
.status.online { color: var(--green); }
|
||||
.status.offline { color: var(--red); }
|
||||
|
||||
/* ── Tab Panels ── */
|
||||
.tab-panel { display: none; padding: 16px; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* ── Live Grid ── */
|
||||
.grid-4x2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
height: calc(100vh - 70px);
|
||||
}
|
||||
.grid-tile {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: border-color .2s, transform .1s;
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
.grid-tile:hover { border-color: var(--accent); transform: scale(1.01); }
|
||||
.grid-tile.offline { opacity: 0.5; cursor: default; }
|
||||
.tile-label {
|
||||
position: absolute; bottom: 6px; left: 8px;
|
||||
font-size: 11px; color: #fff; background: rgba(0,0,0,.6);
|
||||
padding: 2px 8px; border-radius: 4px;
|
||||
}
|
||||
.tile-status {
|
||||
position: absolute; top: 6px; right: 8px;
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
}
|
||||
.tile-status.online { background: var(--green); }
|
||||
.tile-status.offline { background: var(--red); }
|
||||
.tile-placeholder {
|
||||
font-size: 14px; color: var(--text-muted);
|
||||
text-align: center; padding: 10px;
|
||||
}
|
||||
|
||||
/* ── Focus Overlay ── */
|
||||
.focus-overlay {
|
||||
position: fixed; inset: 0; z-index: 200;
|
||||
background: #000; display: flex; flex-direction: column;
|
||||
}
|
||||
.focus-overlay.hidden { display: none; }
|
||||
.focus-toolbar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 20px; background: rgba(0,0,0,.8);
|
||||
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
|
||||
opacity: 0; transition: opacity .3s;
|
||||
}
|
||||
.focus-overlay:hover .focus-toolbar { opacity: 1; }
|
||||
.focus-toolbar button {
|
||||
background: rgba(255,255,255,.15); border: none;
|
||||
color: #fff; padding: 8px 14px; border-radius: 6px;
|
||||
cursor: pointer; font-size: 16px;
|
||||
}
|
||||
.focus-toolbar button:hover { background: rgba(255,255,255,.25); }
|
||||
#focus-title { flex: 1; font-size: 16px; color: #fff; }
|
||||
.focus-time { font-size: 13px; color: var(--text-muted); }
|
||||
#focus-video { flex: 1; width: 100%; object-fit: contain; }
|
||||
.thumb-strip {
|
||||
display: flex; gap: 4px; padding: 8px;
|
||||
background: rgba(0,0,0,.8); overflow-x: auto;
|
||||
}
|
||||
.thumb-strip .thumb {
|
||||
width: 120px; height: 68px; object-fit: cover;
|
||||
border: 2px solid transparent; border-radius: 4px; cursor: pointer; opacity: .6;
|
||||
}
|
||||
.thumb-strip .thumb:hover, .thumb-strip .thumb.active { opacity: 1; border-color: var(--accent); }
|
||||
|
||||
/* ── Playback ── */
|
||||
.playback-controls {
|
||||
display: flex; gap: 12px; align-items: center;
|
||||
margin-bottom: 16px; flex-wrap: wrap;
|
||||
}
|
||||
.playback-controls select, .playback-controls input {
|
||||
padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
|
||||
color: var(--text); border-radius: var(--radius); font-size: 14px;
|
||||
}
|
||||
.clip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
|
||||
.clip-card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 10px; cursor: pointer;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.clip-card:hover { border-color: var(--accent); }
|
||||
.clip-card .clip-time { font-size: 12px; color: var(--text-muted); }
|
||||
.clip-card .clip-size { font-size: 11px; color: var(--text-muted); }
|
||||
.clip-player {
|
||||
position: fixed; inset: 0; z-index: 200; background: #000;
|
||||
display: flex; align-items: center; justify-content: center; flex-direction: column;
|
||||
}
|
||||
.clip-player.hidden { display: none; }
|
||||
.clip-player video { max-width: 90%; max-height: 80%; }
|
||||
.clip-player button { margin-top: 12px; }
|
||||
|
||||
/* ── Settings ── */
|
||||
.settings-toolbar {
|
||||
display: flex; gap: 12px; align-items: center;
|
||||
margin-bottom: 16px; flex-wrap: wrap;
|
||||
}
|
||||
#camera-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); gap: 12px; }
|
||||
.camera-card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 16px;
|
||||
}
|
||||
.camera-card h3 { font-size: 15px; margin-bottom: 8px; }
|
||||
.camera-card label { display: block; font-size: 12px; color: var(--text-muted); margin-top: 8px; }
|
||||
.camera-card input, .camera-card textarea, .camera-card select {
|
||||
width: 100%; padding: 6px 10px; margin-top: 2px;
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
color: var(--text); border-radius: 4px; font-size: 13px;
|
||||
}
|
||||
.camera-card textarea { resize: vertical; min-height: 50px; }
|
||||
.camera-card .row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
.camera-card .row label { margin-top: 0; }
|
||||
.btn-primary { background: var(--accent); color: #fff; border: none; padding: 8px 20px; border-radius: var(--radius); cursor: pointer; font-size: 14px; }
|
||||
.btn-success { background: var(--green); color: #fff; border: none; padding: 8px 20px; border-radius: var(--radius); cursor: pointer; font-size: 14px; }
|
||||
.btn-primary:hover, .btn-success:hover { opacity: .9; }
|
||||
.scan-results { margin-top: 12px; padding: 12px; background: var(--surface); border-radius: var(--radius); }
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 900px) {
|
||||
.grid-4x2 { grid-template-columns: repeat(2, 1fr); grid-template-rows: auto; }
|
||||
#camera-cards { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.grid-4x2 { grid-template-columns: 1fr; }
|
||||
.topbar h1 { font-size: 14px; }
|
||||
.tab { padding: 4px 8px; font-size: 12px; }
|
||||
}
|
||||
141
recorder.go
Normal file
141
recorder.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// NextNVR v0.2.0 — FFmpeg stream recorder
|
||||
// Launches per-camera FFmpeg processes with stream-copy mode and auto-reconnect.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RecorderManager manages FFmpeg recording processes for all cameras.
|
||||
type RecorderManager struct {
|
||||
mu sync.Mutex
|
||||
processes map[string]*recorderProcess
|
||||
config StorageConfig
|
||||
}
|
||||
|
||||
type recorderProcess struct {
|
||||
camera CameraConfig
|
||||
cmd *exec.Cmd
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewRecorderManager creates a new recorder manager.
|
||||
func NewRecorderManager(cfg StorageConfig) *RecorderManager {
|
||||
return &RecorderManager{
|
||||
processes: make(map[string]*recorderProcess),
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// StartAll launches recording goroutines for all enabled cameras.
|
||||
// Cameras are started with a 500ms stagger to avoid I/O and CPU spikes.
|
||||
func (rm *RecorderManager) StartAll(cameras []CameraConfig) {
|
||||
for _, cam := range cameras {
|
||||
if !cam.Enabled || !cam.Record {
|
||||
log.Printf("recorder: skipping camera %s (enabled=%v, record=%v)", cam.ID, cam.Enabled, cam.Record)
|
||||
continue
|
||||
}
|
||||
go rm.startRecorder(cam)
|
||||
time.Sleep(500 * time.Millisecond) // staggered startup
|
||||
}
|
||||
log.Printf("recorder: all cameras launched")
|
||||
}
|
||||
|
||||
// StopAll terminates all recording processes.
|
||||
func (rm *RecorderManager) StopAll() {
|
||||
rm.mu.Lock()
|
||||
defer rm.mu.Unlock()
|
||||
|
||||
for id, proc := range rm.processes {
|
||||
log.Printf("recorder: stopping camera %s", id)
|
||||
close(proc.done)
|
||||
if proc.cmd != nil && proc.cmd.Process != nil {
|
||||
proc.cmd.Process.Signal(os.Interrupt)
|
||||
}
|
||||
}
|
||||
log.Println("recorder: all processes stopped")
|
||||
}
|
||||
|
||||
// startRecorder launches an FFmpeg process for a single camera.
|
||||
// Uses stream-copy mode (-c copy) for zero transcoding overhead.
|
||||
// Segments output into 5-minute MP4 files.
|
||||
// Auto-reconnects on stream failure with a 5-second delay.
|
||||
func (rm *RecorderManager) startRecorder(cam CameraConfig) {
|
||||
rtspURL := cam.RTSPMain
|
||||
if rtspURL == "" {
|
||||
rtspURL = fmt.Sprintf("rtsp://%s:%s@%s:554/Streaming/Channels/101",
|
||||
cam.Username, cam.Password, cam.IP)
|
||||
}
|
||||
|
||||
log.Printf("recorder: starting %s → %s", cam.ID, rtspURL)
|
||||
|
||||
rm.mu.Lock()
|
||||
proc := &recorderProcess{
|
||||
camera: cam,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
rm.processes[cam.ID] = proc
|
||||
rm.mu.Unlock()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-proc.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Create output directory: /mnt/recordings/{cam_id}/{YYYY-MM-DD}/
|
||||
now := time.Now()
|
||||
outDir := filepath.Join(rm.config.RecordingsPath, cam.ID, now.Format("2006-01-02"))
|
||||
if err := os.MkdirAll(outDir, 0755); err != nil {
|
||||
log.Printf("recorder: %s — mkdir failed: %v", cam.ID, err)
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// Output filename: {HH-MM-SS}.mp4
|
||||
outFile := filepath.Join(outDir, now.Format("15-04-05")+".mp4")
|
||||
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-rtsp_transport", "tcp", // TCP is more reliable than UDP for RTSP
|
||||
"-i", rtspURL,
|
||||
"-c", "copy", // stream-copy: zero transcoding
|
||||
"-f", "segment",
|
||||
"-segment_time", "300", // 5-minute segments
|
||||
"-segment_format", "mp4",
|
||||
"-reset_timestamps", "1",
|
||||
"-strftime", "1",
|
||||
outFile,
|
||||
)
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("recorder: %s — FFmpeg start failed: %v", cam.ID, err)
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
proc.cmd = cmd
|
||||
log.Printf("recorder: %s — FFmpeg running (PID %d)", cam.ID, cmd.Process.Pid)
|
||||
|
||||
err := cmd.Wait()
|
||||
proc.cmd = nil
|
||||
|
||||
if err != nil {
|
||||
log.Printf("recorder: %s — FFmpeg exited: %v — reconnecting in 5s", cam.ID, err)
|
||||
} else {
|
||||
log.Printf("recorder: %s — FFmpeg exited cleanly — reconnecting in 5s", cam.ID)
|
||||
}
|
||||
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
97
server.go
Normal file
97
server.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// NextNVR v0.1.0 — HTTP server and routing
|
||||
// Serves the embedded SPA and API endpoints.
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed public/*
|
||||
var publicFiles embed.FS
|
||||
|
||||
// Server wraps the HTTP server and application state.
|
||||
type Server struct {
|
||||
config Config
|
||||
http *http.Server
|
||||
mux *http.ServeMux
|
||||
appConfig *Config // mutable config reference for hot-reload
|
||||
}
|
||||
|
||||
// NewServer creates and configures the HTTP server.
|
||||
func NewServer(cfg Config) (*Server, error) {
|
||||
s := &Server{
|
||||
config: cfg,
|
||||
appConfig: &cfg,
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
|
||||
s.registerRoutes()
|
||||
|
||||
s.http = &http.Server{
|
||||
Addr: cfg.Server.BindHost + cfg.Server.Port,
|
||||
Handler: s.middleware(s.mux),
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ListenAndServe starts the HTTP server.
|
||||
func (s *Server) ListenAndServe() error {
|
||||
log.Printf("HTTP server listening on %s", s.http.Addr)
|
||||
return s.http.ListenAndServe()
|
||||
}
|
||||
|
||||
// Close shuts down the HTTP server.
|
||||
func (s *Server) Close() {
|
||||
if s.http != nil {
|
||||
s.http.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// registerRoutes sets up all API and static file routes.
|
||||
func (s *Server) registerRoutes() {
|
||||
// API endpoints.
|
||||
s.mux.HandleFunc("/api/cameras", s.handleCameras)
|
||||
s.mux.HandleFunc("/api/cameras/", s.handleCameraByID)
|
||||
s.mux.HandleFunc("/api/config", s.handleConfig)
|
||||
s.mux.HandleFunc("/api/scan", s.handleScan)
|
||||
s.mux.HandleFunc("/api/status", s.handleStatus)
|
||||
s.mux.HandleFunc("/api/recordings", s.handleRecordings)
|
||||
|
||||
// Static file server for embedded SPA.
|
||||
publicFS, _ := fs.Sub(publicFiles, "public")
|
||||
s.mux.Handle("/", http.FileServer(http.FS(publicFS)))
|
||||
}
|
||||
|
||||
// middleware wraps handlers with CORS and logging.
|
||||
func (s *Server) middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// API logging.
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
log.Printf("%s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// jsonResponse writes a JSON response with the given status code.
|
||||
func jsonResponse(w http.ResponseWriter, status int, resp APIResponse) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
89
setup.sh
Executable file
89
setup.sh
Executable file
|
|
@ -0,0 +1,89 @@
|
|||
#!/bin/bash
|
||||
# NextNVR v0.1.0 — Deployment Script
|
||||
# Clones the repository and deploys to /opt/nextnvr
|
||||
set -e
|
||||
|
||||
GIT_URL="https://git.lohmar.co.uk/cclohmar/NextNVR.git"
|
||||
DEPLOY_DIR="/opt/nextnvr"
|
||||
SERVICE_NAME="nextnvr"
|
||||
GO_BIN="/home/master/.local/go/bin/go"
|
||||
FFMPEG_BIN="ffmpeg"
|
||||
|
||||
echo "=== NextNVR Deployment ==="
|
||||
echo "Target: ${DEPLOY_DIR}"
|
||||
echo "Git: ${GIT_URL}"
|
||||
|
||||
# 1. Install system dependencies if missing.
|
||||
if ! command -v ffmpeg &>/dev/null; then
|
||||
echo "Installing ffmpeg..."
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq ffmpeg
|
||||
fi
|
||||
|
||||
# 2. Clone or pull repository.
|
||||
if [ -d "${DEPLOY_DIR}" ]; then
|
||||
echo "Repository exists, pulling latest..."
|
||||
cd "${DEPLOY_DIR}"
|
||||
git pull origin main
|
||||
else
|
||||
echo "Cloning repository..."
|
||||
sudo mkdir -p "${DEPLOY_DIR}"
|
||||
sudo chown master:master "${DEPLOY_DIR}"
|
||||
git clone "${GIT_URL}" "${DEPLOY_DIR}"
|
||||
cd "${DEPLOY_DIR}"
|
||||
fi
|
||||
|
||||
# 3. Build the Go binary with embedded static files.
|
||||
echo "Building NextNVR binary..."
|
||||
export PATH=$HOME/.local/go/bin:$PATH
|
||||
export GOPATH=$HOME/go
|
||||
|
||||
go mod tidy
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$(cat VERSION) -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o nextnvr .
|
||||
|
||||
# 4. Copy config if not present.
|
||||
if [ ! -f "${DEPLOY_DIR}/config.yaml" ]; then
|
||||
cp config.yaml "${DEPLOY_DIR}/config.yaml"
|
||||
echo "Default config.yaml created."
|
||||
fi
|
||||
|
||||
# 5. Set permissions.
|
||||
sudo chown -R master:master "${DEPLOY_DIR}"
|
||||
chmod +x "${DEPLOY_DIR}/nextnvr"
|
||||
|
||||
# 6. Install systemd service.
|
||||
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
if [ ! -f "${SERVICE_FILE}" ]; then
|
||||
echo "Creating systemd service..."
|
||||
sudo tee "${SERVICE_FILE}" > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=NextNVR — IP Camera Recorder
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=master
|
||||
WorkingDirectory=${DEPLOY_DIR}
|
||||
ExecStart=${DEPLOY_DIR}/nextnvr --config ${DEPLOY_DIR}/config.yaml
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable "${SERVICE_NAME}"
|
||||
fi
|
||||
|
||||
# 7. Restart service.
|
||||
sudo systemctl restart "${SERVICE_NAME}"
|
||||
sleep 2
|
||||
sudo systemctl status "${SERVICE_NAME}" --no-pager
|
||||
|
||||
echo ""
|
||||
echo "=== Deployment Complete ==="
|
||||
echo "NextNVR running on http://127.0.0.1:8080"
|
||||
echo "Service: sudo systemctl [start|stop|restart|status] ${SERVICE_NAME}"
|
||||
echo "Logs: sudo journalctl -u ${SERVICE_NAME} -f"
|
||||
echo "Config: ${DEPLOY_DIR}/config.yaml"
|
||||
Loading…
Reference in a new issue