// NextNVR v0.2.1 — Storage retention cleaner // Background ticker that purges recordings older than retention_days. // Skips .part.mp4 files (in-progress recordings). // Works with flat directory: /mnt/recordings/{cam-name}/{YYYY-MM-DD-HH-MM}.mp4 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. // Skips *.part.mp4 files (currently being recorded or crashed mid-segment). 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 { log.Printf("cleaner: walk error for %s: %v", path, err) return nil // skip unreadable paths } if info.IsDir() { return nil } // Skip in-progress recordings. if strings.HasSuffix(strings.ToLower(info.Name()), ".part.mp4") { // Also clean up orphaned .part files older than 1 hour // (crashed recordings that were never renamed). if time.Since(info.ModTime()) > 1*time.Hour { size := info.Size() if err := os.Remove(path); err == nil { deleted++ freedBytes += size log.Printf("cleaner: removed orphaned .part file: %s", filepath.Base(path)) } } return nil } // Only process .mp4 files. if !strings.HasSuffix(strings.ToLower(info.Name()), ".mp4") { return nil } // Delete if older than retention cutoff. if info.ModTime().Before(cutoff) { 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) } // Remove empty camera directories. c.pruneEmptyDirs(c.config.RecordingsPath) if deleted > 0 { log.Printf("cleaner: removed %d files, freed %s", deleted, formatBytes(freedBytes)) } } // pruneEmptyDirs removes empty camera directories. func (c *Cleaner) pruneEmptyDirs(root string) { entries, err := os.ReadDir(root) if err != nil { return } for _, entry := range entries { if !entry.IsDir() { continue } dir := filepath.Join(root, entry.Name()) contents, _ := os.ReadDir(dir) if len(contents) == 0 { os.Remove(dir) } } } // 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" }