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.
240 lines
7.3 KiB
Go
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
|
|
}
|