commit e77a4d4237c5bd2978f4b660c8db063d9dcdf353 Author: Colin Powell Date: Mon Sep 29 20:48:48 2025 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba8558d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +images +camcap.json diff --git a/build/camcap b/build/camcap new file mode 100755 index 0000000..adaefb1 Binary files /dev/null and b/build/camcap differ diff --git a/build/camcap_linux b/build/camcap_linux new file mode 100755 index 0000000..608cf03 Binary files /dev/null and b/build/camcap_linux differ diff --git a/camcap.go b/camcap.go new file mode 100644 index 0000000..e53c9c8 --- /dev/null +++ b/camcap.go @@ -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) +} + diff --git a/camcap.json.example b/camcap.json.example new file mode 100644 index 0000000..731f750 --- /dev/null +++ b/camcap.json.example @@ -0,0 +1,8 @@ +{ + "images_path": "images", + "ntfy_url": "https://ntfy.sh/" + "poll_interval_minutes": 5, + "cameras": { + "goats": { "host": "goat-cam.service", "port": "8082" }, + } +}