Add a settings page (/settings) and API (/api/settings GET/PUT) to edit encoding profiles (crf/preset per media type), lp, OMDb key, log retention, and delete-originals from the browser, persisted to a settings table in logs.db. config.yaml seeds the store on first run; afterwards the DB is the source of truth (paths.* and http_addr stay config-only). Each job snapshots the current settings, so changes apply to the next encode with no restart. PUT validates ranges (crf 0-63, preset 0-13, lp >=0, retention >=1) before persisting. No auth, by design for now (isolated network) — see #2. Closes #1
170 lines
4.4 KiB
Go
170 lines
4.4 KiB
Go
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)
|
|
}
|
|
|
|
// DB exposes the underlying connection so the settings store can share the
|
|
// same logs.db file rather than opening a second database.
|
|
func (l *Logger) DB() *sql.DB { return l.db }
|
|
|
|
// SetRetention updates the purge horizon at runtime (e.g. from the settings UI).
|
|
// Takes effect on the next purge. Ignored for non-positive values.
|
|
func (l *Logger) SetRetention(days int) {
|
|
if days > 0 {
|
|
l.retentionDays = days
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|