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.
174 lines
5 KiB
Go
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
|
|
}
|