155 lines
3.7 KiB
Go
155 lines
3.7 KiB
Go
// 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"
|
|
}
|