|
@@ -0,0 +1,165 @@
|
|
|
|
+package main
|
|
|
|
+
|
|
|
|
+import (
|
|
|
|
+ "encoding/json"
|
|
|
|
+ "fmt"
|
|
|
|
+ "io"
|
|
|
|
+ "net/http"
|
|
|
|
+ "os"
|
|
|
|
+ "path/filepath"
|
|
|
|
+ "sync"
|
|
|
|
+ "time"
|
|
|
|
+ "strings"
|
|
|
|
+)
|
|
|
|
+
|
|
|
|
+// Camera represents a single camera
|
|
|
|
+type Camera struct {
|
|
|
|
+ Host string `json:"host"`
|
|
|
|
+ Port string `json:"port"`
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// Config represents the full JSON config
|
|
|
|
+type Config struct {
|
|
|
|
+ ImagesPath string `json:"images_path"`
|
|
|
|
+ NtfyURL string `json:"ntfy_url"`
|
|
|
|
+ PollIntervalMinutes int `json:"poll_interval_minutes"`
|
|
|
|
+ Cameras map[string]Camera `json:"cameras"`
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+var config Config
|
|
|
|
+
|
|
|
|
+func main() {
|
|
|
|
+ loadConfig()
|
|
|
|
+
|
|
|
|
+ interval := time.Duration(config.PollIntervalMinutes) * time.Minute
|
|
|
|
+ ticker := time.NewTicker(interval)
|
|
|
|
+ defer ticker.Stop()
|
|
|
|
+
|
|
|
|
+ // Run immediately at startup
|
|
|
|
+ go pollImages()
|
|
|
|
+
|
|
|
|
+ for range ticker.C {
|
|
|
|
+ go pollImages()
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ select {} // keep program running
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// loadConfig searches multiple locations for the JSON config
|
|
|
|
+func loadConfig() {
|
|
|
|
+ paths := []string{
|
|
|
|
+ "/usr/local/etc/camcap.json",
|
|
|
|
+ "/usr/etc/camcap.json",
|
|
|
|
+ "./camcap.json",
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ var configFile string
|
|
|
|
+ for _, path := range paths {
|
|
|
|
+ if _, err := os.Stat(path); err == nil {
|
|
|
|
+ configFile = path
|
|
|
|
+ break
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if configFile == "" {
|
|
|
|
+ panic("No configuration file found in /usr/local/etc, /usr/etc, or current directory")
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ f, err := os.Open(configFile)
|
|
|
|
+ if err != nil {
|
|
|
|
+ panic(err)
|
|
|
|
+ }
|
|
|
|
+ defer f.Close()
|
|
|
|
+
|
|
|
|
+ if err := json.NewDecoder(f).Decode(&config); err != nil {
|
|
|
|
+ panic(err)
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ fmt.Println("Loaded config from:", configFile)
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// pollImages downloads snapshots concurrently
|
|
|
|
+func pollImages() {
|
|
|
|
+ fmt.Println("Starting image poll at", time.Now())
|
|
|
|
+
|
|
|
|
+ var wg sync.WaitGroup
|
|
|
|
+ errCh := make(chan error, len(config.Cameras))
|
|
|
|
+
|
|
|
|
+ for title, cam := range config.Cameras {
|
|
|
|
+ url := fmt.Sprintf("http://%s.service:%s/snapshot", cam.Host, cam.Port)
|
|
|
|
+
|
|
|
|
+ wg.Add(1)
|
|
|
|
+ go func(title, url string) {
|
|
|
|
+ defer wg.Done()
|
|
|
|
+ if err := downloadImageWithTitle(url, title); err != nil {
|
|
|
|
+ errCh <- fmt.Errorf("%s: %v", title, err)
|
|
|
|
+ } else {
|
|
|
|
+ fmt.Println("Downloaded:", title)
|
|
|
|
+ }
|
|
|
|
+ }(title, url)
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ wg.Wait()
|
|
|
|
+ close(errCh)
|
|
|
|
+
|
|
|
|
+ for err := range errCh {
|
|
|
|
+ fmt.Println("Error:", err)
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ fmt.Println("Finished image poll at", time.Now())
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// downloadImageWithTitle saves the image to timestamped directories
|
|
|
|
+func downloadImageWithTitle(url, title string) error {
|
|
|
|
+ today := time.Now()
|
|
|
|
+ dir := filepath.Join(config.ImagesPath, title,
|
|
|
|
+ fmt.Sprintf("%d", today.Year()),
|
|
|
|
+ fmt.Sprintf("%02d", today.Month()),
|
|
|
|
+ fmt.Sprintf("%02d", today.Day()))
|
|
|
|
+
|
|
|
|
+ if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
|
|
|
+ return err
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ timestamp := today.Format("20060102150405")
|
|
|
|
+ fileName := fmt.Sprintf("%s-%s.jpg", title, timestamp)
|
|
|
|
+ filePath := filepath.Join(dir, fileName)
|
|
|
|
+
|
|
|
|
+ resp, err := http.Get(url)
|
|
|
|
+ if err != nil {
|
|
|
|
+ notifyError(fmt.Sprintf("Connection error for %s: %v", title, err))
|
|
|
|
+ return err
|
|
|
|
+ }
|
|
|
|
+ defer resp.Body.Close()
|
|
|
|
+
|
|
|
|
+ if resp.StatusCode != http.StatusOK {
|
|
|
|
+ notifyError(fmt.Sprintf("HTTP error for %s: %s", title, resp.Status))
|
|
|
|
+ return fmt.Errorf("bad status: %s", resp.Status)
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ out, err := os.Create(filePath)
|
|
|
|
+ if err != nil {
|
|
|
|
+ notifyError(fmt.Sprintf("File creation error for %s: %v", title, err))
|
|
|
|
+ return err
|
|
|
|
+ }
|
|
|
|
+ defer out.Close()
|
|
|
|
+
|
|
|
|
+ _, err = io.Copy(out, resp.Body)
|
|
|
|
+ if err != nil {
|
|
|
|
+ notifyError(fmt.Sprintf("File write error for %s: %v", title, err))
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return err
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+// notifyError posts errors to ntfy
|
|
|
|
+func notifyError(message string) {
|
|
|
|
+ resp, err := http.Post(config.NtfyURL, "text/plain", strings.NewReader(message))
|
|
|
|
+ if err != nil {
|
|
|
|
+ fmt.Println("Failed to send ntfy notification:", err)
|
|
|
|
+ return
|
|
|
|
+ }
|
|
|
|
+ defer resp.Body.Close()
|
|
|
|
+ fmt.Println("Sent ntfy error:", message)
|
|
|
|
+}
|
|
|
|
+
|