diff --git a/cmd/videnc/main.go b/cmd/videnc/main.go index 6861660..6b8e71a 100644 --- a/cmd/videnc/main.go +++ b/cmd/videnc/main.go @@ -6,18 +6,22 @@ import ( "encoding/hex" "flag" "fmt" + "net/http" "os" "os/signal" "path/filepath" "regexp" "strings" "syscall" + "time" "videnc-vibe/internal/config" "videnc-vibe/internal/encoder" "videnc-vibe/internal/logger" "videnc-vibe/internal/metadata" "videnc-vibe/internal/mover" + "videnc-vibe/internal/server" + "videnc-vibe/internal/status" "videnc-vibe/internal/watcher" "videnc-vibe/pkg/types" ) @@ -53,7 +57,8 @@ func main() { } defer log.Close() - enc := encoder.New(log) + tracker := status.New() + enc := encoder.New(log, tracker) if err := enc.CheckDeps(); err != nil { log.Error("Dependency check failed", err.Error()) fmt.Fprintf(os.Stderr, "Dependency check failed: %v\n", err) @@ -65,17 +70,38 @@ func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() + if addr := *cfg.HTTPAddr; addr != "" { + srv := &http.Server{Addr: addr, Handler: server.New(tracker, log, cfg.Paths.Input).Handler()} + go func() { + log.Info(fmt.Sprintf("Status server listening on %s", addr)) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Error("Status server", err.Error()) + } + }() + go func() { + <-ctx.Done() + shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutCancel() + _ = srv.Shutdown(shutCtx) + }() + } + w := watcher.New(cfg.Paths.Input, 15) w.Start(ctx, func(ctx context.Context, inputPath string) error { - return processFile(ctx, inputPath, cfg, enc, metaClient, log) + return processFile(ctx, inputPath, cfg, enc, metaClient, log, tracker) }) log.Info("videnc-vibe started") } -func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *encoder.Encoder, metaClient *metadata.Client, log *logger.Logger) error { +func processFile(ctx context.Context, inputPath string, cfg *types.Config, enc *encoder.Encoder, metaClient *metadata.Client, log *logger.Logger, tracker *status.Tracker) error { log.Info(fmt.Sprintf("Processing: %s", inputPath)) + // Mark this file as the active job (phase: probing) and clear the tracker + // back to idle on every exit path — success, failure, or cancellation. + tracker.Begin(inputPath) + defer tracker.Idle() + filename := filepath.Base(inputPath) isSeries, imdbID, tvmazeID, season, episode := metadata.ParseFilename(filename) diff --git a/internal/config/config.go b/internal/config/config.go index 4c67297..d1573f1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -79,6 +79,10 @@ func Load(configPath string) (*types.Config, error) { if cfg.LogRetentionDays == 0 { cfg.LogRetentionDays = 7 } + if cfg.HTTPAddr == nil { + def := ":8080" + cfg.HTTPAddr = &def + } return &cfg, nil } diff --git a/internal/encoder/encoder.go b/internal/encoder/encoder.go index 27e6bf0..7383afb 100644 --- a/internal/encoder/encoder.go +++ b/internal/encoder/encoder.go @@ -1,6 +1,7 @@ package encoder import ( + "bytes" "context" "encoding/json" "fmt" @@ -10,8 +11,10 @@ import ( "sort" "strconv" "strings" + "time" "videnc-vibe/internal/logger" + "videnc-vibe/internal/status" "videnc-vibe/pkg/types" ) @@ -22,6 +25,7 @@ type Encoder struct { ffprobePath string opusencPath string log *logger.Logger + tracker *status.Tracker } type StreamInfo struct { @@ -45,12 +49,13 @@ type StreamMetadata struct { Title string `json:"title"` } -func New(log *logger.Logger) *Encoder { +func New(log *logger.Logger, tracker *status.Tracker) *Encoder { return &Encoder{ ffmpegPath: "ffmpeg", ffprobePath: "ffprobe", opusencPath: "opusenc", log: log, + tracker: tracker, } } @@ -71,6 +76,82 @@ func (e *Encoder) runCmd(ctx context.Context, label, file, name string, args []s return out, err } +// runCmdProgress runs ffmpeg with `-progress pipe:1`, streaming progress +// samples to the tracker (instead of buffering all output like runCmd). stdout +// carries only the key=value progress stream; stderr carries real errors and is +// returned for the caller's error message. Used solely for the video encode — +// the one step long enough to be worth watching live. +func (e *Encoder) runCmdProgress(ctx context.Context, label, file, name string, args []string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + return nil, err + } + + // Reads stdout to EOF (when ffmpeg exits), so Wait below is safe afterwards. + var lastLog time.Time + _ = status.ScanProgress(stdout, func(s status.ProgressSample) { + if e.tracker == nil { + return + } + e.tracker.Update(s.OutTimeSec, s.FPS, s.Speed) + if time.Since(lastLog) >= 5*time.Second { + lastLog = time.Now() + snap := e.tracker.Snapshot() + e.log.Progress(fmt.Sprintf("encoding %s · %.1f%% · %.1ffps · %.2fx · ETA %s", + filepath.Base(file), snap.Percent, snap.FPS, snap.Speed, fmtETA(snap.ETASec))) + } + }) + + err = cmd.Wait() + + extra, _ := json.Marshal(struct { + Cmd string `json:"cmd"` + Output string `json:"output"` + }{ + Cmd: name + " " + strings.Join(args, " "), + Output: stderr.String(), + }) + e.log.Debug(label, file, string(extra)) + return stderr.Bytes(), err +} + +// fmtETA renders a seconds count as a compact "11h03m" / "4m12s" / "9s" string. +func fmtETA(sec int) string { + if sec <= 0 { + return "--" + } + d := time.Duration(sec) * time.Second + switch { + case d >= time.Hour: + return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60) + case d >= time.Minute: + return fmt.Sprintf("%dm%02ds", int(d.Minutes()), sec%60) + default: + return fmt.Sprintf("%ds", sec) + } +} + +// GetDuration returns the source container duration in seconds via ffprobe. +func (e *Encoder) GetDuration(ctx context.Context, path string) (float64, error) { + args := []string{"-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path} + out, err := e.runCmd(ctx, "ffprobe duration", path, e.ffprobePath, args) + if err != nil { + return 0, fmt.Errorf("ffprobe duration: %w", err) + } + d, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64) + if err != nil { + return 0, fmt.Errorf("parsing duration %q: %w", strings.TrimSpace(string(out)), err) + } + return d, nil +} + func (e *Encoder) CheckDeps() error { if _, err := exec.LookPath(e.ffmpegPath); err != nil { return fmt.Errorf("ffmpeg not found") @@ -246,6 +327,9 @@ func (e *Encoder) calculateZscaleWidth(ctx context.Context, path string, origina } func (e *Encoder) Transcode(ctx context.Context, input, workDir string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error { + if e.tracker != nil { + e.tracker.SetPhase(status.PhaseAudio) + } audioWavs, err := e.extractAudio(ctx, input, workDir, streamLangs) if err != nil { return fmt.Errorf("extracting audio: %w", err) @@ -319,6 +403,18 @@ func (e *Encoder) encodeOpus(ctx context.Context, wavs []string, workDir string) func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFiles []string, job *types.Job, metadata *types.Metadata, interlaced bool, streamLangs []StreamMetadata) error { outFile := filepath.Join(workDir, "output.mkv") + // Switch the tracker to the encode phase and feed it the source duration so + // progress samples can be turned into a percentage. A failed duration probe + // just means no percent — it must not abort the encode. + if e.tracker != nil { + e.tracker.SetPhase(status.PhaseEncoding) + if dur, derr := e.GetDuration(ctx, input); derr == nil { + e.tracker.SetTotal(dur) + } else { + e.log.Debug("duration probe failed", input, derr.Error()) + } + } + svtParams := fmt.Sprintf("film-grain=10:film-grain-denoise=1:scd=1:qm-min=4:qm-max=15:keyint=10s") zscaleStr, newWidth, err := e.calculateZscaleWidth(ctx, input, 0) @@ -412,8 +508,8 @@ func (e *Encoder) encodeVideo(ctx context.Context, input, workDir string, opusFi args = append(args, outFile) - args = append([]string{"-hide_banner", "-v", "error"}, args...) - out, err := e.runCmd(ctx, "ffmpeg encode", input, e.ffmpegPath, args) + args = append([]string{"-hide_banner", "-v", "error", "-progress", "pipe:1", "-nostats"}, args...) + out, err := e.runCmdProgress(ctx, "ffmpeg encode", input, e.ffmpegPath, args) if err != nil { return fmt.Errorf("ffmpeg encode: %s %w", out, err) } diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 16eb701..bfe7408 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -87,6 +87,13 @@ func (l *Logger) Info(message string) { 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) @@ -106,6 +113,40 @@ 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 diff --git a/internal/server/index.html b/internal/server/index.html new file mode 100644 index 0000000..5d62250 --- /dev/null +++ b/internal/server/index.html @@ -0,0 +1,187 @@ + + + + + +videnc·vibe — status + + + + +
+ $ videnc·vibe + connecting +
+ +
+

Current job

+
+
+ +
+
+

Queue

+ +
+
+

Recent events

+
+
+
+ + + + + diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..03d0368 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,71 @@ +// Package server exposes a read-only HTTP status endpoint for the daemon: +// the live encode progress, the pending input queue, and recent log events. +package server + +import ( + _ "embed" + "encoding/json" + "net/http" + "path/filepath" + + "videnc-vibe/internal/logger" + "videnc-vibe/internal/status" + "videnc-vibe/internal/watcher" +) + +//go:embed index.html +var indexHTML []byte + +type Server struct { + tracker *status.Tracker + log *logger.Logger + inputDir string +} + +func New(tracker *status.Tracker, log *logger.Logger, inputDir string) *Server { + return &Server{tracker: tracker, log: log, inputDir: inputDir} +} + +// Handler returns the mux for all status routes. Phase 3 adds "/" (the HTML +// dashboard) to this same mux. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/status", s.handleStatus) + mux.HandleFunc("/", s.handleIndex) + return mux +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(indexHTML) +} + +type statusResponse struct { + Current status.Snapshot `json:"current"` + Queue []string `json:"queue"` + Recent []logger.LogEntry `json:"recent"` +} + +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + queue := []string{} + for _, f := range watcher.InputFiles(s.inputDir) { + queue = append(queue, filepath.Base(f)) + } + + recent, err := s.log.RecentLogs(50) + if err != nil { + http.Error(w, "reading logs", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(statusResponse{ + Current: s.tracker.Snapshot(), + Queue: queue, + Recent: recent, + }) +} diff --git a/internal/status/status.go b/internal/status/status.go new file mode 100644 index 0000000..9083f48 --- /dev/null +++ b/internal/status/status.go @@ -0,0 +1,201 @@ +// Package status tracks the live state of the single in-flight encode job. +// The watcher processes one file at a time, so one mutex-guarded value is +// enough — no per-job table, no concurrency design. +package status + +import ( + "bufio" + "io" + "strconv" + "strings" + "sync" + "time" +) + +// Phase labels for the current job. +const ( + PhaseIdle = "idle" + PhaseProbing = "probing" + PhaseAudio = "audio" + PhaseEncoding = "encoding" +) + +// Tracker holds live progress for the active job. Safe for concurrent use: +// the encode goroutine writes, HTTP/log readers call Snapshot. +type Tracker struct { + mu sync.RWMutex + file string + phase string + totalSec float64 // source duration; 0 until known + outTime float64 // encoded position in seconds + fps float64 + speed float64 + startedAt time.Time +} + +// Snapshot is an immutable view of the tracker for readers. +type Snapshot struct { + File string `json:"file"` + Phase string `json:"phase"` + Percent float64 `json:"percent"` + FPS float64 `json:"fps"` + Speed float64 `json:"speed"` + ElapsedSec int `json:"elapsed_sec"` + ETASec int `json:"eta_sec"` + StartedAt time.Time `json:"started_at"` +} + +func New() *Tracker { + return &Tracker{phase: PhaseIdle} +} + +// Begin marks the start of a new job, resetting all progress fields. +func (t *Tracker) Begin(file string) { + t.mu.Lock() + defer t.mu.Unlock() + t.file = file + t.phase = PhaseProbing + t.totalSec, t.outTime, t.fps, t.speed = 0, 0, 0, 0 + t.startedAt = time.Now() +} + +func (t *Tracker) SetPhase(p string) { + t.mu.Lock() + defer t.mu.Unlock() + t.phase = p +} + +// SetTotal records the source duration in seconds (from ffprobe). +func (t *Tracker) SetTotal(seconds float64) { + t.mu.Lock() + defer t.mu.Unlock() + t.totalSec = seconds +} + +// Update records one ffmpeg -progress sample. +func (t *Tracker) Update(outTimeSec, fps, speed float64) { + t.mu.Lock() + defer t.mu.Unlock() + t.outTime, t.fps, t.speed = outTimeSec, fps, speed +} + +// Idle clears the tracker when no job is running. +func (t *Tracker) Idle() { + t.mu.Lock() + defer t.mu.Unlock() + t.file = "" + t.phase = PhaseIdle + t.totalSec, t.outTime, t.fps, t.speed = 0, 0, 0, 0 + t.startedAt = time.Time{} +} + +func (t *Tracker) Snapshot() Snapshot { + t.mu.RLock() + defer t.mu.RUnlock() + s := Snapshot{ + File: t.file, + Phase: t.phase, + FPS: t.fps, + Speed: t.speed, + StartedAt: t.startedAt, + } + if !t.startedAt.IsZero() { + s.ElapsedSec = int(time.Since(t.startedAt).Seconds()) + } + if t.totalSec > 0 { + s.Percent = t.outTime / t.totalSec * 100 + if s.Percent > 100 { + s.Percent = 100 + } + if t.speed > 0 { + remaining := t.totalSec - t.outTime + if remaining < 0 { + remaining = 0 + } + s.ETASec = int(remaining / t.speed) + } + } + return s +} + +// ProgressSample is one completed ffmpeg -progress block. +type ProgressSample struct { + OutTimeSec float64 + FPS float64 + Speed float64 + Done bool // progress=end +} + +// ScanProgress reads ffmpeg `-progress` key=value output from r and calls +// onSample once per block (each block is terminated by a "progress=" line). +// Returns when r is exhausted. +func ScanProgress(r io.Reader, onSample func(ProgressSample)) error { + sc := bufio.NewScanner(r) + var cur ProgressSample + for sc.Scan() { + if parseProgressLine(sc.Text(), &cur) { + onSample(cur) + cur = ProgressSample{} + } + } + return sc.Err() +} + +// parseProgressLine folds one "key=value" line into s. Returns true when the +// line closes a block (key == "progress"). +// +// ponytail: ffmpeg field naming drifts between builds — out_time_us is the +// modern microsecond field; out_time_ms is historically ALSO microseconds (a +// known mislabel); out_time is the "HH:MM:SS.ffffff" string. Prefer out_time_us, +// fall back to the others only if it hasn't set a value this block. Verify +// against the ffmpeg in the Docker image if percentages look off. +func parseProgressLine(line string, s *ProgressSample) (complete bool) { + k, v, ok := strings.Cut(line, "=") + if !ok { + return false + } + k, v = strings.TrimSpace(k), strings.TrimSpace(v) + switch k { + case "out_time_us": + if us, err := strconv.ParseFloat(v, 64); err == nil { + s.OutTimeSec = us / 1e6 + } + case "out_time_ms": + if s.OutTimeSec == 0 { + if ms, err := strconv.ParseFloat(v, 64); err == nil { + s.OutTimeSec = ms / 1e6 // really microseconds, see note above + } + } + case "out_time": + if s.OutTimeSec == 0 { + s.OutTimeSec = parseTimecode(v) + } + case "fps": + if f, err := strconv.ParseFloat(v, 64); err == nil { + s.FPS = f + } + case "speed": + if f, err := strconv.ParseFloat(strings.TrimSuffix(v, "x"), 64); err == nil { + s.Speed = f // "N/A" leaves it 0 + } + case "progress": + s.Done = v == "end" + return true + } + return false +} + +// parseTimecode parses "HH:MM:SS.ffffff" into seconds; 0 on bad input. +func parseTimecode(tc string) float64 { + parts := strings.Split(tc, ":") + if len(parts) != 3 { + return 0 + } + h, err1 := strconv.ParseFloat(parts[0], 64) + m, err2 := strconv.ParseFloat(parts[1], 64) + sec, err3 := strconv.ParseFloat(parts[2], 64) + if err1 != nil || err2 != nil || err3 != nil { + return 0 + } + return h*3600 + m*60 + sec +} diff --git a/internal/status/status_test.go b/internal/status/status_test.go new file mode 100644 index 0000000..fbcc4cd --- /dev/null +++ b/internal/status/status_test.go @@ -0,0 +1,89 @@ +package status + +import ( + "strings" + "testing" +) + +func TestScanProgress(t *testing.T) { + tests := []struct { + name string + input string + wantOutSec, wantFPS, wantSpd float64 + wantDone bool + }{ + { + name: "out_time_us preferred over ms and string", + input: "frame=120\nfps=24.00\nout_time_us=5000000\nout_time_ms=9999999\nout_time=00:00:09.000000\nspeed=1.02x\nprogress=continue\n", + wantOutSec: 5, wantFPS: 24, wantSpd: 1.02, wantDone: false, + }, + { + name: "out_time string fallback when no us field", + input: "fps=12\nout_time=00:01:30.500000\nspeed=0.09x\nprogress=continue\n", + wantOutSec: 90.5, wantFPS: 12, wantSpd: 0.09, wantDone: false, + }, + { + name: "end block", + input: "out_time_us=7200000000\nspeed=2x\nprogress=end\n", + wantOutSec: 7200, wantFPS: 0, wantSpd: 2, wantDone: true, + }, + { + name: "speed N/A leaves zero", + input: "out_time_us=1000000\nspeed=N/A\nprogress=continue\n", + wantOutSec: 1, wantFPS: 0, wantSpd: 0, wantDone: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var last ProgressSample + n := 0 + if err := ScanProgress(strings.NewReader(tt.input), func(s ProgressSample) { last = s; n++ }); err != nil { + t.Fatalf("ScanProgress: %v", err) + } + if n != 1 { + t.Fatalf("got %d samples, want 1", n) + } + if last.OutTimeSec != tt.wantOutSec || last.FPS != tt.wantFPS || last.Speed != tt.wantSpd || last.Done != tt.wantDone { + t.Errorf("got %+v, want out=%v fps=%v spd=%v done=%v", last, tt.wantOutSec, tt.wantFPS, tt.wantSpd, tt.wantDone) + } + }) + } +} + +func TestScanProgressMultipleBlocks(t *testing.T) { + in := "out_time_us=1000000\nspeed=1x\nprogress=continue\nout_time_us=2000000\nspeed=1x\nprogress=end\n" + var got []ProgressSample + if err := ScanProgress(strings.NewReader(in), func(s ProgressSample) { got = append(got, s) }); err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("got %d blocks, want 2", len(got)) + } + if got[0].OutTimeSec != 1 || got[1].OutTimeSec != 2 || got[0].Done || !got[1].Done { + t.Errorf("blocks = %+v", got) + } +} + +func TestSnapshotPercentAndETA(t *testing.T) { + tr := New() + tr.Begin("episode.mkv") + tr.SetTotal(100) + tr.Update(25, 10, 0.5) // 25% done, 75s left at 0.5x -> 150s ETA + s := tr.Snapshot() + if s.Percent != 25 { + t.Errorf("Percent = %v, want 25", s.Percent) + } + if s.ETASec != 150 { + t.Errorf("ETASec = %v, want 150", s.ETASec) + } + if s.File != "episode.mkv" || s.Phase != PhaseProbing { + t.Errorf("File/Phase = %q/%q", s.File, s.Phase) + } +} + +func TestSnapshotIdleNoDivByZero(t *testing.T) { + s := New().Snapshot() // no Begin, totalSec 0 + if s.Percent != 0 || s.ETASec != 0 || s.Phase != PhaseIdle { + t.Errorf("idle snapshot = %+v", s) + } +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 8f5b2ea..f4cc2cc 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "time" "videnc-vibe/pkg/types" @@ -28,6 +29,19 @@ var inputExtensions = []string{ "webm", "wmv", "flv", } +// InputFiles returns the source files currently in dir that the watcher would +// consider, sorted. The status server uses this to report the pending queue, so +// it stays in sync with inputExtensions. +func InputFiles(dir string) []string { + var files []string + for _, ext := range inputExtensions { + matches, _ := filepath.Glob(filepath.Join(dir, "*."+ext)) + files = append(files, matches...) + } + sort.Strings(files) + return files +} + // fileStat is the (mtime, size) pair used to decide whether a file has settled // between two consecutive ticks. type fileStat struct { diff --git a/pkg/types/types.go b/pkg/types/types.go index 9180869..9ba7886 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -34,8 +34,11 @@ type Metadata struct { type Config struct { OMDBAPIKey string `yaml:"omdb_api_key"` LogRetentionDays int `yaml:"log_retention_days"` - Encoding EncodingConfig - Paths PathsConfig + // HTTPAddr is the status server listen address. Absent (nil) defaults to + // ":8080"; an explicit empty string disables the server. + HTTPAddr *string `yaml:"http_addr"` + Encoding EncodingConfig + Paths PathsConfig } type EncodingConfig struct {