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) }