NextNVR/cleaner.go

135 lines
3 KiB
Go

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