Vellum/internal/core/config.go
mithral 051169a18a Documentation, and make a fresh clone actually work
Docs
- docs/ARCHITECTURE.md: how the pieces fit and why. The request path, the six
  enforced layers, the data model, the scanner, the job queue, direct-play
  playback, and the three protocol surfaces over one application.
- docs/decisions/: seven ADRs with the alternatives that were rejected —
  SQLite-with-a-seam, direct-play-only, mark-missing-never-delete, the modular
  monolith, the one item-access chokepoint, the table-backed queue, and
  fixed-width timestamps.
- README: the layout section was stale (it predated catalog, home, scanner,
  providers, engine, probe, playback, apitoken, arch and the dto packages) and
  now lists what exists, plus links to the new docs.

Two real bugs, both found by verifying the docs instead of trusting them

1. The CLI could not run on a fresh install. `Config.Validate` created the data
   and config directories, but the CLI never called it, so `vellum user add` —
   the recovery path when you are locked out — failed with "unable to open
   database file" until the server had booted at least once. Split the directory
   creation into `Config.EnsureDirs`, call it from the CLI, and keep the media
   root check out of it: an admin command must not fail because a library is
   unmounted. The CLI tests now point DBPath inside DataDir, exactly as
   LoadConfig derives it, so they exercise the fresh-install path; removing
   EnsureDirs fails three of them.

2. A fresh clone could not build at all. `//go:embed all:dist` fails to compile
   when web/dist is absent, and web/.gitignore expected a `dist/.gitkeep` that
   was never created. Added it, so a clone builds and the server logs
   "web UI not embedded; run the frontend build to include it" — the intended
   behaviour for someone who only wants the API.

Also: the httpapi tests depended on a built frontend and FAILED rather than
skipping, so the suite was red on any fresh clone. findAsset and readAsset now
skip with a message naming the missing step. They still RUN when a bundle is
present (verified: zero skips here), so they have not been quietly disabled.

Verified from a simulated clone of tracked files only: go build exit 0, 25
packages pass, the server boots and applies all 12 migrations, and the CLI
creates a user and takes a backup. gofmt clean, vet exit 0, svelte-check 0/0.
2026-10-12 00:18:11 -04:00

174 lines
5 KiB
Go

package core
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// Config is the complete runtime configuration, loaded from the environment.
// Every field has a working default so the binary runs with no configuration
// at all — important for "podman run" first-run experience.
type Config struct {
// Listen is the address the HTTP server binds, e.g. ":7788".
Listen string
// DataDir holds the SQLite database and persistent state.
DataDir string
// ConfigDir holds derived, regenerable artifacts (thumbnails, page cache).
ConfigDir string
// MediaDirs are read-only library roots. Vellum never writes here.
MediaDirs []string
DBPath string
LogLevel string
LogFormat string
// AdminUser / AdminPassword create the first administrator, but only when
// the users table is empty. Ignored on any established database.
AdminUser string
AdminPassword string
// ComicVineAPIKey enables comic metadata. ComicVine has no keyless tier, so
// the provider is simply absent when this is empty.
ComicVineAPIKey string
// IGDBClientID / IGDBClientSecret enable game metadata via Twitch's
// client-credentials flow. Both must be set for the provider to appear.
IGDBClientID string
IGDBClientSecret string
CookieName string
CookieSecure bool
SessionTTL time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
ShutdownGrace time.Duration
}
// LoadConfig reads configuration from the environment and fills in defaults.
// It does not create directories; call Validate for that.
func LoadConfig() (Config, error) {
c := Config{
Listen: env("VELLUM_LISTEN", ":7788"),
DataDir: env("VELLUM_DATA_DIR", "./data"),
ConfigDir: env("VELLUM_CONFIG_DIR", "./config"),
MediaDirs: envList("VELLUM_MEDIA_DIRS", nil),
LogLevel: env("VELLUM_LOG_LEVEL", "info"),
LogFormat: env("VELLUM_LOG_FORMAT", "text"), // text|json
AdminUser: env("VELLUM_ADMIN_USER", ""),
AdminPassword: env("VELLUM_ADMIN_PASSWORD", ""),
ComicVineAPIKey: env("VELLUM_COMICVINE_API_KEY", ""),
IGDBClientID: env("VELLUM_IGDB_CLIENT_ID", ""),
IGDBClientSecret: env("VELLUM_IGDB_CLIENT_SECRET", ""),
CookieName: env("VELLUM_COOKIE_NAME", "vellum_session"),
// Secure defaults to false so plain-HTTP development works. Production
// behind TLS must set VELLUM_COOKIE_SECURE=true; boot logs a warning
// when it is off.
CookieSecure: envBool("VELLUM_COOKIE_SECURE", false),
SessionTTL: envDuration("VELLUM_SESSION_TTL", 30*24*time.Hour),
ReadTimeout: envDuration("VELLUM_READ_TIMEOUT", 15*time.Second),
WriteTimeout: envDuration("VELLUM_WRITE_TIMEOUT", 0), // 0 = no limit; streaming needs this
IdleTimeout: envDuration("VELLUM_IDLE_TIMEOUT", 120*time.Second),
ShutdownGrace: envDuration("VELLUM_SHUTDOWN_GRACE", 15*time.Second),
}
abs, err := filepath.Abs(c.DataDir)
if err != nil {
return Config{}, fmt.Errorf("resolve data dir: %w", err)
}
c.DataDir = abs
c.DBPath = filepath.Join(abs, "vellum.db")
if abs, err := filepath.Abs(c.ConfigDir); err == nil {
c.ConfigDir = abs
}
return c, nil
}
// EnsureDirs creates the data and config directories if missing.
//
// Separate from Validate because the CLI needs the directories but has no
// interest in the media roots: `vellum user add` must work on a fresh install,
// and must not fail because a library happens to be unmounted.
func (c Config) EnsureDirs() error {
for _, dir := range []string{c.DataDir, c.ConfigDir} {
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create %s: %w", dir, err)
}
}
return nil
}
// Validate creates the data and config directories if missing and checks that
// the configured media roots exist. A missing media root is an error, not a
// warning: silently scanning nothing is worse than failing loudly at boot.
func (c Config) Validate() error {
if err := c.EnsureDirs(); err != nil {
return err
}
for _, dir := range c.MediaDirs {
info, err := os.Stat(dir)
if err != nil {
return fmt.Errorf("media dir %s: %w", dir, err)
}
if !info.IsDir() {
return fmt.Errorf("media dir %s: not a directory", dir)
}
}
return nil
}
func env(key, def string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return def
}
// envList parses a colon-separated list, the same separator as $PATH.
func envList(key string, def []string) []string {
v, ok := os.LookupEnv(key)
if !ok || strings.TrimSpace(v) == "" {
return def
}
var out []string
for _, part := range strings.Split(v, ":") {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
return out
}
func envDuration(key string, def time.Duration) time.Duration {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def
}
d, err := time.ParseDuration(v)
if err != nil {
return def
}
return d
}
func envBool(key string, def bool) bool {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}