diff --git a/api.go b/api.go index 839a8a7..ed3010d 100644 --- a/api.go +++ b/api.go @@ -90,6 +90,8 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: safe}) case http.MethodPost: + s.mu.Lock() + defer s.mu.Unlock() var newCfg Config if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil { jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid JSON: " + err.Error()}) @@ -135,7 +137,7 @@ func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { go2rtcURL := "http://127.0.0.1:1984/api/stream.mjpeg?src=" + camID + "_" + streamType - resp, err := http.Get(go2rtcURL) + resp, err := httpClient.Get(go2rtcURL) if err != nil { http.Error(w, "stream unavailable", http.StatusServiceUnavailable) return @@ -363,7 +365,7 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) { snapName := strings.Replace(name, "rec_", "snap_", 1) snapName = strings.Replace(snapName, ".mp4", ".jpg", 1) snapPath := camDir + "/" + snapName - if _, err := os.Stat(recDir + "/" + snapName); err == nil { + if _, err := os.Stat(scanDir + "/" + snapName); err == nil { clip.Snap = snapPath } @@ -381,7 +383,12 @@ func (s *Server) handleRecordings(w http.ResponseWriter, r *http.Request) { } } - clips = append([]Clip{clip}, clips...) // newest first + clips = append(clips, clip) + } + + // Reverse — newest first. + for i, j := 0, len(clips)-1; i < j; i, j = i+1, j-1 { + clips[i], clips[j] = clips[j], clips[i] } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: clips}) diff --git a/config.go b/config.go index bdb1d2e..bacfb93 100644 --- a/config.go +++ b/config.go @@ -125,7 +125,7 @@ func SaveConfig(cfg Config, path string) error { header := []byte("# NextNVR Configuration\n# Generated by NextNVR setup\n\n") out := append(header, data...) - if err := os.WriteFile(path, out, 0644); err != nil { + if err := os.WriteFile(path, out, 0600); err != nil { return fmt.Errorf("writing config: %w", err) } return nil diff --git a/go2rtc.go b/go2rtc.go index 2e9b468..9509b85 100644 --- a/go2rtc.go +++ b/go2rtc.go @@ -68,7 +68,9 @@ func (g *Go2RTCManager) Stop() { if g.cmd != nil && g.cmd.Process != nil { log.Println("go2rtc: stopping...") g.cmd.Process.Signal(os.Interrupt) + g.mu.Unlock() time.Sleep(2 * time.Second) + g.mu.Lock() g.cmd.Process.Kill() g.running = false log.Println("go2rtc: stopped") @@ -139,5 +141,5 @@ func (g *Go2RTCManager) writeConfig(cameras []CameraConfig) error { sb.WriteString(fmt.Sprintf(" %s_main: %s\n", cam.ID, mainURL)) } - return os.WriteFile("go2rtc.yaml", []byte(sb.String()), 0644) + return os.WriteFile("go2rtc.yaml", []byte(sb.String()), 0600) } diff --git a/onvif.go b/onvif.go index f8cb35b..974011c 100644 --- a/onvif.go +++ b/onvif.go @@ -10,7 +10,6 @@ package main import ( "bytes" - "encoding/xml" "fmt" "io" "net" @@ -238,6 +237,3 @@ func buildRTSPURLs(ip, user, pass, manufacturer string) (string, string) { return replacer.Replace(patterns.main), replacer.Replace(patterns.sub) } - -// suppress unused import warning for encoding/xml. -var _ = xml.Unmarshal diff --git a/public/app.js b/public/app.js index ee796e1..e24bd54 100644 --- a/public/app.js +++ b/public/app.js @@ -61,8 +61,12 @@ async function loadCameras() { } // ── Live Grid ── +let gridRefreshTimer = null; + function renderLiveGrid() { const grid = document.getElementById('grid'); + // Clear previous refresh timer. + if (gridRefreshTimer) { clearInterval(gridRefreshTimer); gridRefreshTimer = null; } grid.innerHTML = ''; for (let i = 0; i < 8; i++) { const cam = cameras[i]; @@ -80,16 +84,23 @@ function renderLiveGrid() { ${cam.name} `; - setInterval(() => { - const img = tile.querySelector('img'); - if (img) img.src = `/recordings/${cam.name || cam.id}/latest.jpg?t=${Date.now()}`; - }, 3000); tile.addEventListener('click', () => openFocus(cam)); } else { tile.innerHTML = 'No Camera'; } grid.appendChild(tile); } + + // Single global refresh timer — avoids interval leak. + gridRefreshTimer = setInterval(() => { + grid.querySelectorAll('.grid-tile:not(.offline) img.grid-snap').forEach(img => { + const camId = img.closest('.grid-tile').dataset.camId; + if (camId) { + const cam = cameras.find(c => c.id === camId); + if (cam) img.src = `/recordings/${cam.name || cam.id}/latest.jpg?t=${Date.now()}`; + } + }); + }, 3000); } // ── Focus Overlay ── @@ -153,16 +164,36 @@ async function loadClips() { } function renderClips(clips) { const grid = document.getElementById('pb-clips'); - if (clips.length === 0) { grid.innerHTML = '

No recordings found for this selection.

'; return; } - grid.innerHTML = clips.map(c => ` -
- ${c.time} -
${c.live ? '🔴 ' : '🎬 '}${c.time}
-
${c.name}
-
${formatSize(c.size)}
-
- `).join(''); + grid.innerHTML = ''; + if (clips.length === 0) { + grid.innerHTML = '

No recordings found for this selection.

'; + return; + } + clips.forEach(c => { + const card = document.createElement('div'); + card.className = 'clip-card'; + card.addEventListener('click', () => playClip(c.path)); + if (c.snap) { + const thumb = document.createElement('img'); + thumb.className = 'clip-thumb'; + thumb.src = c.snap; + thumb.loading = 'lazy'; + thumb.onerror = () => { thumb.style.display = 'none'; }; + card.appendChild(thumb); + } + const timeDiv = document.createElement('div'); + timeDiv.textContent = (c.live ? '🔴 ' : '🎬 ') + c.time; + card.appendChild(timeDiv); + const nameDiv = document.createElement('div'); + nameDiv.className = 'clip-time'; + nameDiv.textContent = c.name; + card.appendChild(nameDiv); + const sizeDiv = document.createElement('div'); + sizeDiv.className = 'clip-size'; + sizeDiv.textContent = formatSize(c.size); + card.appendChild(sizeDiv); + grid.appendChild(card); + }); } function playClip(path) { document.getElementById('pb-player').classList.remove('hidden'); diff --git a/public/index.html b/public/index.html index d7cac12..9a7f0fc 100644 --- a/public/index.html +++ b/public/index.html @@ -3,7 +3,7 @@ - NextNVR — Casa Alba Mindelo + NextNVR diff --git a/recorder.go b/recorder.go index adaf950..15df2e1 100644 --- a/recorder.go +++ b/recorder.go @@ -85,7 +85,7 @@ func (rm *RecorderManager) startRecorder(cam CameraConfig) { cam.Username, cam.Password, cam.IP) } - log.Printf("recorder: starting %s → %s", cam.ID, rtspURL) + log.Printf("recorder: starting %s", cam.ID) rm.mu.Lock() proc := &recorderProcess{ diff --git a/server.go b/server.go index 25585fe..c3546ce 100644 --- a/server.go +++ b/server.go @@ -11,6 +11,7 @@ import ( "net/http/httputil" "net/url" "strings" + "sync" ) //go:embed public/* @@ -22,6 +23,7 @@ type Server struct { http *http.Server mux *http.ServeMux appConfig *Config // mutable config reference for hot-reload + mu sync.RWMutex } // NewServer creates and configures the HTTP server.