Five providers existed and none covered the "video" kind, so every movie and show got a title from its filename and nothing else - no poster, no plot, no cast, no year. That is the gap between "a folder of videos with a nice web UI" and a media server. TMDB keeps films and television in separate namespaces behind separate endpoints, and a Query does not say which one it is. Rather than guessing from the title, Search asks both at once and merges by TMDB popularity, which is the only ranking signal available without a second request per item. Speed is the point, so the two requests run concurrently over one derived context on a single shared client, and whichever fails first cancels the other: one dead endpoint cannot hold the result hostage while the healthy one is waited out. Verified red-green - serialising the two fetches makes TestTMDBSearchesConcurrently fail at 2s. Two corrections made in review, both found by reading the code rather than trusting the report: 1. ExternalID was the bare TMDB id, so film 603 and show 603 - different works - would have been stored identically in provider_ids. Namespaced as movie/603 and tv/603. Nothing resolves an item from an external_id today, so this was latent rather than broken, but the table is populated as enrichment runs and correcting it later would have needed a data migration. Pinned by TestTMDBExternalIDIsNamespaced. 2. Query.Year was added but never populated, so the year filter - the main way to tell a remake from the original - never applied. catalog.Item already carried Year; the enricher now passes it. No cover bytes are fetched here: the provider returns image URLs and the existing enricher caches what it uses. A missing key logs the env var and skips, so the feature is discoverable from the boot log. Not verified: the live TMDB API. Tests run against httptest servers only.
234 lines
7.1 KiB
Go
234 lines
7.1 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
|
|
|
|
// 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", ""),
|
|
|
|
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
|
|
}
|