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 => ` -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 @@ -