// Command runner is the homelab worker binary. // // It polls PocketBase for pending scrape and audio tasks, executes them, and // writes results back. It connects directly to PocketBase and MinIO using // admin credentials loaded from environment variables. // // Usage: // // runner # start polling loop (blocks until SIGINT/SIGTERM) package main import ( "context" "fmt" "log/slog" "os" "os/signal" "runtime" "syscall" "time" "github.com/getsentry/sentry-go" "github.com/libnovel/backend/internal/browser" "github.com/libnovel/backend/internal/config" "github.com/libnovel/backend/internal/kokoro" "github.com/libnovel/backend/internal/meili" "github.com/libnovel/backend/internal/novelfire" "github.com/libnovel/backend/internal/runner" "github.com/libnovel/backend/internal/storage" ) // version and commit are set at build time via -ldflags. var ( version = "dev" commit = "unknown" ) func main() { if err := run(); err != nil { fmt.Fprintf(os.Stderr, "runner: fatal: %v\n", err) os.Exit(1) } } func run() error { cfg := config.Load() // ── Sentry / GlitchTip error tracking ──────────────────────────────────── if dsn := os.Getenv("GLITCHTIP_DSN"); dsn != "" { if err := sentry.Init(sentry.ClientOptions{ Dsn: dsn, Release: version + "@" + commit, TracesSampleRate: 0.1, }); err != nil { fmt.Fprintf(os.Stderr, "runner: sentry init warning: %v\n", err) } else { defer sentry.Flush(2 * time.Second) } } // ── Logger ────────────────────────────────────────────────────────────── log := buildLogger(cfg.LogLevel) log.Info("runner starting", "version", version, "commit", commit, "worker_id", cfg.Runner.WorkerID, ) // ── Context: cancel on SIGINT / SIGTERM ───────────────────────────────── ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() // ── Storage ───────────────────────────────────────────────────────────── store, err := storage.NewStore(ctx, cfg, log) if err != nil { return fmt.Errorf("init storage: %w", err) } // ── Browser / Scraper ─────────────────────────────────────────────────── workers := cfg.Runner.Workers if workers <= 0 { workers = runtime.NumCPU() } timeout := cfg.Runner.Timeout if timeout <= 0 { timeout = 90 * time.Second } browserClient := browser.NewDirectClient(browser.Config{ MaxConcurrent: workers, Timeout: timeout, }) novel := novelfire.New(browserClient, log) // ── Kokoro ────────────────────────────────────────────────────────────── var kokoroClient kokoro.Client if cfg.Kokoro.URL != "" { kokoroClient = kokoro.New(cfg.Kokoro.URL) log.Info("kokoro TTS enabled", "url", cfg.Kokoro.URL) } else { log.Warn("KOKORO_URL not set — audio tasks will fail") kokoroClient = &noopKokoro{} } // ── Meilisearch ───────────────────────────────────────────────────────── var searchIndex meili.Client if cfg.Meilisearch.URL != "" { if err := meili.Configure(cfg.Meilisearch.URL, cfg.Meilisearch.APIKey); err != nil { log.Warn("meilisearch configure failed — search indexing disabled", "err", err) searchIndex = meili.NoopClient{} } else { searchIndex = meili.New(cfg.Meilisearch.URL, cfg.Meilisearch.APIKey) log.Info("meilisearch enabled", "url", cfg.Meilisearch.URL) } } else { log.Info("MEILI_URL not set — search indexing disabled") searchIndex = meili.NoopClient{} } // ── Runner ────────────────────────────────────────────────────────────── rCfg := runner.Config{ WorkerID: cfg.Runner.WorkerID, PollInterval: cfg.Runner.PollInterval, MaxConcurrentScrape: cfg.Runner.MaxConcurrentScrape, MaxConcurrentAudio: cfg.Runner.MaxConcurrentAudio, OrchestratorWorkers: workers, MetricsAddr: cfg.Runner.MetricsAddr, CatalogueRefreshInterval: cfg.Runner.CatalogueRefreshInterval, SkipInitialCatalogueRefresh: cfg.Runner.SkipInitialCatalogueRefresh, } deps := runner.Dependencies{ Consumer: store, BookWriter: store, BookReader: store, AudioStore: store, CoverStore: store, SearchIndex: searchIndex, Novel: novel, Kokoro: kokoroClient, Log: log, } r := runner.New(rCfg, deps) return r.Run(ctx) } // ── Helpers ─────────────────────────────────────────────────────────────────── func buildLogger(level string) *slog.Logger { var lvl slog.Level switch level { case "debug": lvl = slog.LevelDebug case "warn": lvl = slog.LevelWarn case "error": lvl = slog.LevelError default: lvl = slog.LevelInfo } return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})) } // noopKokoro is a no-op implementation used when KOKORO_URL is not set. type noopKokoro struct{} func (n *noopKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, error) { return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)") } func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) { return nil, nil }