Vellum/internal/core/config.go
mithral 82ded6a9c7 Add a ThePornDB provider for the adult kind
The adult kind scanned and browsed but could never be enriched, because no
provider claimed it. ThePornDB does: it is the catalogue the Stash ecosystem
uses for scenes and movies, and it is keyed the same way IGDB and TMDB are, so
it registers only when VELLUM_TPDB_API_KEY is set and otherwise logs the env
var at boot.

The API shape came from the live OpenAPI spec rather than the docs, which is
where the search parameter lives: it is `parse`, not `q` or `title`, and the
endpoint is /scenes with a `data` envelope.

Not verified against the live API: it needs a token I do not have, so the
tests run against httptest only. The field names are taken from the published
schema, which is the best available substitute.

The provider also does not use ThePornDB's hash lookup, which is the more
accurate way to match: it needs a file hash computed at scan time, and
matching by parsed title and date works today without that.
2026-10-12 11:30:58 -04:00

240 lines
7.3 KiB
Go

package core
import (
"fmt"
"net"
"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
// TMDBAPIKey enables movie and TV metadata. TMDB has no keyless tier, so the
// provider is simply absent when this is empty.
TMDBAPIKey string
// TPDBAPIKey enables adult scene metadata via ThePornDB. It has no keyless
// tier, so the provider is simply absent when this is empty.
TPDBAPIKey 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
// PublicURL is the external origin Vellum is reached at, e.g.
// "https://media.example.com". Empty means "not published", which is the
// case for plain-HTTP LAN use. It is the signal that the deployment sits
// behind a TLS-terminating proxy, so it also drives the CookieSecure default.
PublicURL string
// TrustedProxies are the networks allowed to speak for a client in
// X-Forwarded-For. Empty means trust nobody: only RemoteAddr is used.
TrustedProxies []*net.IPNet
// Discovery advertises the server on the LAN over mDNS so clients find it
// without a typed URL.
Discovery 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) {
publicURL := env("VELLUM_PUBLIC_URL", "")
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", ""),
TMDBAPIKey: env("VELLUM_TMDB_API_KEY", ""),
TPDBAPIKey: env("VELLUM_TPDB_API_KEY", ""),
IGDBClientID: env("VELLUM_IGDB_CLIENT_ID", ""),
IGDBClientSecret: env("VELLUM_IGDB_CLIENT_SECRET", ""),
CookieName: env("VELLUM_COOKIE_NAME", "vellum_session"),
// Secure defaults to true for an https:// PublicURL — a published origin
// is TLS-terminated, so plain-HTTP dev still gets false with it unset.
// VELLUM_COOKIE_SECURE overrides both ways; boot logs a warning when off.
CookieSecure: envBool("VELLUM_COOKIE_SECURE", strings.HasPrefix(publicURL, "https://")),
PublicURL: publicURL,
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),
Discovery: !envOff("VELLUM_DISCOVERY"),
}
proxies, err := envCIDRs("VELLUM_TRUSTED_PROXIES")
if err != nil {
return Config{}, err
}
c.TrustedProxies = proxies
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
}
// envCIDRs parses a comma-separated CIDR list, e.g.
// "172.16.0.0/12,127.0.0.1/32". A malformed entry is an error rather than a
// silent skip: dropping a proxy network quietly widens the trust boundary,
// which is exactly the failure this setting exists to prevent.
func envCIDRs(key string) ([]*net.IPNet, error) {
var out []*net.IPNet
for _, part := range strings.Split(env(key, ""), ",") {
if part = strings.TrimSpace(part); part == "" {
continue
}
_, n, err := net.ParseCIDR(part)
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
out = append(out, n)
}
return out, nil
}
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
}
// envOff reads a switch that is ON unless it is explicitly turned off. Unlike
// envBool it accepts the on/off spellings operators actually type, because
// strconv.ParseBool rejects "off" and would silently leave the feature on.
func envOff(key string) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "off", "no", "false", "0":
return true
}
return false
}