package main import ( "context" "fmt" "io" "log" "math/big" "os" "os/exec" "os/signal" "path" "slices" "sort" "strconv" "strings" "syscall" "time" "ffmpeg.d/pkg/fs" ) func main() { ctx, can := signal.NotifyContext(context.Background(), syscall.SIGINT) defer can() if err := Run(ctx, os.Args[1:]); err != nil { panic(err) } } func Run(ctx context.Context, args []string) error { cams, err := lsd(args[0]) if err != nil { return fmt.Errorf("failed to lsd %s: %w", args[0], err) } movementInterval, err := time.ParseDuration(args[1]) if err != nil { return err } for _, cam := range cams { files, err := lsf(cam) if err != nil { return fmt.Errorf("failed to lsf %s: %w", cam, err) } else if len(files) < 1 { continue } series := []string{} for _, f := range files { series = append(series, strings.Split(path.Base(f), ".")[0]) } sort.Strings(series) series = slices.Compact(series) series = slices.DeleteFunc(series, func(s string) bool { return path.Base(s) > time.Now().Add(-10*time.Minute).Format("2006-01-02T15-04-05.") }) for _, series := range series { if err := func() error { seriesFiles := []string{} for _, file := range files { if strings.HasPrefix(path.Base(file), series) { seriesFiles = append(seriesFiles, file) } } if seriesHasMovement, err := seriesHasMovement(ctx, seriesFiles, movementInterval); err != nil { return err } else if seriesHasMovement > -1 { outd := strings.ReplaceAll(cam, "record", "movement") os.MkdirAll(outd, os.ModePerm) fileWithMovement := seriesFiles[seriesHasMovement] if b, err := os.ReadFile(fileWithMovement); err != nil { return err } else if err := os.WriteFile(path.Join(outd, series+path.Ext(fileWithMovement)), b, os.ModePerm); err != nil { return err } firstFileInfo, err := os.Stat(seriesFiles[0]) if err != nil { return err } lastFileInfo, err := os.Stat(seriesFiles[len(seriesFiles)-1]) if err != nil { return err } durationSeconds := lastFileInfo.ModTime().Sub(firstFileInfo.ModTime()).Seconds() frames := len(seriesFiles) fps := int(float64(frames) / durationSeconds) log.Println("found movement in", series, "over", durationSeconds, "seconds at", fps, "fps") cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-framerate", strconv.Itoa(fps), "-pattern_type", "glob", "-i", path.Join(cam, series)+".*.jpg", "-r", strconv.Itoa(fps), path.Join(outd, series+".mp4"), ) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to persist series %s: (%w) %s", series, err, out) } } else { log.Println("no movement in", series) } for _, seriesFile := range seriesFiles { if err := os.Remove(seriesFile); err != nil { return fmt.Errorf("failed to rm series %s[%s]: %w", series, seriesFile, err) } } return nil }(); err != nil { return err } } } for _, cam := range cams { camMovementD := strings.ReplaceAll(cam, "record", "movement") movementFiles, err := lsf(camMovementD) if err != nil { return err } sort.Strings(movementFiles) var sizeOfCamDMiB int64 for _, movementFile := range movementFiles { stat, err := os.Stat(movementFile) if err != nil { return err } fileSizeMiB := stat.Size() / 1024 / 1024 sizeOfCamDMiB += fileSizeMiB } GiB100 := int64(100 * 1024) for sizeOfCamDMiB > GiB100 && len(movementFiles) > 0 { oldestFile := movementFiles[0] oldestFileStat, err := os.Stat(oldestFile) if err != nil { return err } oldestFileSizeMiB := oldestFileStat.Size() / 1024 / 1024 log.Println("deleting old file", oldestFile) if err := os.Remove(oldestFile); err != nil { return err } sizeOfCamDMiB -= oldestFileSizeMiB movementFiles = movementFiles[1:] } } return nil } func seriesHasMovement(ctx context.Context, files []string, movementInterval time.Duration) (int, error) { if len(files) < 1 { return -1, nil } f := files[0] fStat, err := os.Stat(f) if err != nil { return -1, err } fTime := fStat.ModTime() for i := 1; i < len(files); i++ { g := files[i] gStat, err := os.Stat(g) if err != nil { return -1, err } gTime := gStat.ModTime() if gTime.Sub(fTime) < movementInterval { continue } if hasMovement, err := hasMovement(ctx, f, g); err != nil { return -1, fmt.Errorf("failed to check for movement between %s and %s: %w", f, g, err) } else if hasMovement { return i, nil } f = g fTime = gTime } return -1, nil } func hasMovement(ctx context.Context, a, b string) (bool, error) { sizeOfCmd := exec.CommandContext(ctx, "identify", a) sizeOfOutput, err := sizeOfCmd.CombinedOutput() if err != nil { return false, fmt.Errorf("failed to identify %s: (%w) %s", a, err, sizeOfOutput) } hw := strings.Fields(string(sizeOfOutput))[2] h, err := strconv.ParseInt(strings.Split(hw, "x")[0], 10, 16) if err != nil { return false, fmt.Errorf("failed parsing %s for HxW: %w", hw, err) } w, err := strconv.ParseInt(strings.Split(hw, "x")[1], 10, 16) if err != nil { return false, fmt.Errorf("failed parsing %s for HxW: %w", hw, err) } total := h * w compareCmd := exec.CommandContext(ctx, "compare", "-metric", "AE", "-fuzz", "15%", a, b, "/dev/null") compareOutput, _ := compareCmd.CombinedOutput() f, _, err := big.ParseFloat(string(compareOutput), 10, 0, big.ToNearestEven) if err != nil { return false, fmt.Errorf("failed to parse %s for a number of changed pixels: %w", compareOutput, err) } i := new(big.Int) f.Int(i) delta := i.Int64() percentPixelsChanged := int(100.0 * float64(delta) / float64(total)) return percentPixelsChanged > 10, nil } func mv(wPath, rPath string) error { r, err := os.Open(rPath) if err != nil { return err } defer r.Close() os.MkdirAll(path.Dir(wPath), os.ModePerm) w, err := os.Create(wPath) if err != nil { return err } defer w.Close() if _, err := io.Copy(w, r); err != nil { w.Close() os.Remove(wPath) return err } return os.Remove(rPath) } func lsd(d string) ([]string, error) { return fs.LsD(d) } func lsf(d string) ([]string, error) { return fs.LsF(d) }