// NextNVR v0.2.0 — Storage retention cleaner // Background ticker that purges recordings older than retention_days. package main import ( "log" "os" "path/filepath" "strings" "time" ) // Cleaner manages the periodic purge of old recordings. type Cleaner struct { config StorageConfig stopCh chan struct{} running bool } // NewCleaner creates a new retention cleaner. func NewCleaner(cfg StorageConfig) *Cleaner { if cfg.CleanupIntervalMins <= 0 { cfg.CleanupIntervalMins = 60 } return &Cleaner{ config: cfg, stopCh: make(chan struct{}), } } // Start begins the periodic cleanup loop. func (c *Cleaner) Start() { c.running = true interval := time.Duration(c.config.CleanupIntervalMins) * time.Minute log.Printf("cleaner: starting — retention=%d days, interval=%v, path=%s", c.config.RetentionDays, interval, c.config.RecordingsPath) // Run immediately on startup. go c.purge() ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-c.stopCh: c.running = false log.Println("cleaner: stopped") return case <-ticker.C: go c.purge() } } } // Stop signals the cleaner to shutdown. func (c *Cleaner) Stop() { close(c.stopCh) } // purge walks the recordings directory and deletes files older than retention_days. func (c *Cleaner) purge() { cutoff := time.Now().Add(-time.Duration(c.config.RetentionDays) * 24 * time.Hour) deleted := 0 var freedBytes int64 err := filepath.Walk(c.config.RecordingsPath, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // skip unreadable files } if info.IsDir() { return nil } if !strings.HasSuffix(strings.ToLower(info.Name()), ".mp4") { return nil } if info.ModTime().After(cutoff) { return nil } size := info.Size() if err := os.Remove(path); err != nil { log.Printf("cleaner: failed to remove %s: %v", path, err) return nil } deleted++ freedBytes += size return nil }) if err != nil { log.Printf("cleaner: walk error: %v", err) } // Prune empty directories. c.pruneEmptyDirs(c.config.RecordingsPath) if deleted > 0 { log.Printf("cleaner: removed %d files, freed %s", deleted, formatBytes(freedBytes)) } } // pruneEmptyDirs removes empty date directories within the recordings tree. func (c *Cleaner) pruneEmptyDirs(root string) { filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil || !info.IsDir() || path == root { return nil } entries, _ := os.ReadDir(path) if len(entries) == 0 { os.Remove(path) } return nil }) } // formatBytes returns a human-readable byte count. func formatBytes(bytes int64) string { const unit = 1024 if bytes < unit { return itoa(int(bytes)) + " B" } div, exp := int64(unit), 0 for n := bytes / unit; n >= unit; n /= unit { div *= unit exp++ } return itoa(int(float64(bytes)/float64(div)*10)/10) + " " + string("KMGTPE"[exp]) + "B" }