// NextNVR — MIT License // Copyright (c) 2026 NextNVR Contributors // SPDX-License-Identifier: MIT // Purges rec_*.mp4 and snap_*.jpg files older than retention_days. // Skips latest.jpg (live wall) and rec_*.part.mp4 (in-progress recordings). 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) 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 expired files. func (c *Cleaner) purge() { cutoff := time.Now().Add(-time.Duration(c.config.RetentionDays) * 24 * time.Hour) deleted := 0 var freedBytes int64 filepath.Walk(c.config.RecordingsPath, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() { return nil } name := info.Name() // Skip live wall snapshot and in-progress recordings. if name == "latest.jpg" { return nil } if strings.HasSuffix(name, ".part.mp4") { // Clean orphaned .part files older than 1 hour. if time.Since(info.ModTime()) > 1*time.Hour { size := info.Size() os.Remove(path) deleted++ freedBytes += size } return nil } // Process rec_*.mp4 and snap_*.jpg files. isRec := strings.HasPrefix(name, "rec_") && strings.HasSuffix(name, ".mp4") isSnap := strings.HasPrefix(name, "snap_") && strings.HasSuffix(name, ".jpg") if !isRec && !isSnap { return nil } 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) } else { deleted++ freedBytes += size } } return nil }) 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) } } }