package logger import ( "database/sql" "fmt" "os" "path/filepath" "time" _ "github.com/mattn/go-sqlite3" ) const ( LevelDebug = "debug" LevelInfo = "info" LevelError = "error" ) type Logger struct { db *sql.DB dbPath string retentionDays int } // New opens (or creates) logs.db inside logDir, applies the schema, and runs // an initial retention purge. retentionDays <= 0 falls back to 7. func New(logDir string, retentionDays int) (*Logger, error) { if retentionDays <= 0 { retentionDays = 7 } if err := os.MkdirAll(logDir, 0755); err != nil { return nil, fmt.Errorf("creating log directory: %w", err) } dbPath := filepath.Join(logDir, "logs.db") db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000") if err != nil { return nil, fmt.Errorf("opening log db: %w", err) } schema := ` CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, level TEXT NOT NULL, message TEXT NOT NULL, file TEXT, extra TEXT ); CREATE INDEX IF NOT EXISTS idx_logs_ts_level ON logs(ts, level); ` if _, err := db.Exec(schema); err != nil { db.Close() return nil, fmt.Errorf("applying log schema: %w", err) } l := &Logger{db: db, dbPath: dbPath, retentionDays: retentionDays} l.purge() return l, nil } func (l *Logger) purge() { cutoff := fmt.Sprintf("-%d days", l.retentionDays) _, _ = l.db.Exec(`DELETE FROM logs WHERE ts < datetime('now', ?)`, cutoff) } func (l *Logger) write(level, message, file, extra string) { _, _ = l.db.Exec( `INSERT INTO logs(level, message, file, extra) VALUES(?, ?, ?, ?)`, level, message, nullIfEmpty(file), nullIfEmpty(extra), ) } func nullIfEmpty(s string) interface{} { if s == "" { return nil } return s } func stamp() string { return time.Now().Format("2006-01-02 15:04:05") } func (l *Logger) Info(message string) { fmt.Fprintf(os.Stdout, "[%s] INFO: %s\n", stamp(), message) l.write(LevelInfo, message, "", "") } // Progress prints to stdout only, without a db row. For high-frequency, // ephemeral telemetry (live encode progress) that would otherwise bury real // events in logs.db and churn until retention purges it. func (l *Logger) Progress(message string) { fmt.Fprintf(os.Stdout, "[%s] INFO: %s\n", stamp(), message) } func (l *Logger) Error(message, errStr string) { msg := fmt.Sprintf("%s: %s", message, errStr) fmt.Fprintf(os.Stderr, "[%s] ERROR: %s\n", stamp(), msg) l.write(LevelError, msg, "", "") } func (l *Logger) ErrorFile(file, message, errStr string) { msg := fmt.Sprintf("%s: %s", message, errStr) fmt.Fprintf(os.Stderr, "[%s] ERROR: %s (file: %s)\n", stamp(), msg, file) l.write(LevelError, msg, file, "") } // Debug records a debug entry to the database only. extra is an opaque string, // typically JSON, used for ffprobe output, API response bodies, or command // stdout/stderr captures. Pass "" if not applicable. func (l *Logger) Debug(message, file, extra string) { l.write(LevelDebug, message, file, extra) } // LogEntry is one row returned by RecentLogs, shaped for the status feed. type LogEntry struct { TS string `json:"ts"` Level string `json:"level"` Message string `json:"message"` File string `json:"file,omitempty"` } // RecentLogs returns the newest-first non-debug entries, capped at n. Debug // rows (per-command ffprobe/ffmpeg/API dumps) are excluded — the status feed // wants real events, not invocation noise. func (l *Logger) RecentLogs(n int) ([]LogEntry, error) { if l.db == nil { return nil, nil } rows, err := l.db.Query( `SELECT ts, level, message, COALESCE(file, '') FROM logs WHERE level != ? ORDER BY id DESC LIMIT ?`, LevelDebug, n) if err != nil { return nil, err } defer rows.Close() var out []LogEntry for rows.Next() { var e LogEntry if err := rows.Scan(&e.TS, &e.Level, &e.Message, &e.File); err != nil { return nil, err } out = append(out, e) } return out, rows.Err() } func (l *Logger) Close() { if l.db == nil { return } l.purge() l.db.Close() l.db = nil }