Files
gronod 9a02a27c7f
Build and publish / Test and build (darwin) (push) Successful in 2m4s
Build and publish / Test and build (linux) (push) Successful in 3m3s
Build and publish / Test and build (windows) (push) Successful in 2m50s
Build and publish / Build and publish Docker image (push) Successful in 2m4s
Fix config.Load treating empty env vars as overrides
A set-but-empty environment variable wiped the corresponding .env
value, so an EMBY_SERVER_URL='' guard on CI broke the config tests.
Empty variables now count as unset, and the tests clear the config env
keys explicitly for hermeticity.
2026-09-20 23:03:25 +01:00

143 lines
4.6 KiB
Go

// Package config loads Emby.MCP settings from a .env file and/or the process
// environment, matching the semantics of the original Python implementation
// (python-dotenv's dotenv_values for our key set).
package config
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
"time"
)
// Transport names for MCP_TRANSPORT.
const (
TransportStdio = "stdio"
TransportHTTP = "http" // MCP streamable HTTP (SSE streaming), endpoint /mcp
)
// Config holds the Emby.MCP runtime configuration.
type Config struct {
ServerURL string // EMBY_SERVER_URL, e.g. "http://localhost:8096"
Username string // EMBY_USERNAME
Password string // EMBY_PASSWORD
APIKey string // EMBY_API_KEY (alternative to username/password)
UserID string // EMBY_USER_ID (required with API key auth)
VerifySSL bool // EMBY_VERIFY_SSL, default true
MaxChunkSize int // LLM_MAX_ITEMS; 0 or negative means no chunking limit
Transport string // MCP_TRANSPORT: "stdio" (default) or "http"
ListenAddr string // MCP_LISTEN_ADDR, default "127.0.0.1:8080"
SessionTimeout time.Duration // MCP_SESSION_TIMEOUT, default 30m; <=0 disables
}
// Load reads the .env file at path (if it exists), lets real environment
// variables override file values, and validates the result.
func Load(path string) (*Config, error) {
vals := map[string]string{}
if path != "" {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("cannot find the .env file. Ensure that it exists in the same directory as the binary")
}
defer f.Close()
if err := parseDotEnv(f, vals); err != nil {
return nil, err
}
}
// Real environment overrides file values; empty variables are unset.
for _, k := range []string{
"EMBY_SERVER_URL", "EMBY_USERNAME", "EMBY_PASSWORD",
"EMBY_API_KEY", "EMBY_USER_ID", "EMBY_VERIFY_SSL", "LLM_MAX_ITEMS",
"MCP_TRANSPORT", "MCP_LISTEN_ADDR", "MCP_SESSION_TIMEOUT",
} {
if v, ok := os.LookupEnv(k); ok && v != "" {
vals[k] = v
}
}
cfg := &Config{
ServerURL: strings.TrimRight(vals["EMBY_SERVER_URL"], "/"),
Username: vals["EMBY_USERNAME"],
Password: vals["EMBY_PASSWORD"],
APIKey: vals["EMBY_API_KEY"],
UserID: vals["EMBY_USER_ID"],
VerifySSL: strToBool(defaultStr(vals["EMBY_VERIFY_SSL"], "True")),
}
if v, err := strconv.Atoi(strings.TrimSpace(vals["LLM_MAX_ITEMS"])); err == nil {
cfg.MaxChunkSize = v
}
cfg.Transport = strings.ToLower(defaultStr(vals["MCP_TRANSPORT"], TransportStdio))
if cfg.Transport != TransportStdio && cfg.Transport != TransportHTTP {
return nil, fmt.Errorf("invalid MCP_TRANSPORT %q: expected %q or %q", cfg.Transport, TransportStdio, TransportHTTP)
}
cfg.ListenAddr = defaultStr(vals["MCP_LISTEN_ADDR"], "127.0.0.1:8080")
cfg.SessionTimeout = 30 * time.Minute
if s := strings.TrimSpace(vals["MCP_SESSION_TIMEOUT"]); s != "" {
d, err := time.ParseDuration(s)
if err != nil {
return nil, fmt.Errorf("invalid MCP_SESSION_TIMEOUT %q: %v", s, err)
}
cfg.SessionTimeout = d
}
if cfg.ServerURL == "" {
return nil, fmt.Errorf("missing required variable EMBY_SERVER_URL")
}
// HTTP mode authenticates each request against Emby; server-side
// credentials are only required for stdio mode.
if cfg.Transport == TransportHTTP {
return cfg, nil
}
if cfg.APIKey != "" {
if cfg.UserID == "" {
return nil, fmt.Errorf("EMBY_USER_ID is required when using EMBY_API_KEY")
}
} else if cfg.Username == "" || cfg.Password == "" {
return nil, fmt.Errorf("missing required variables. Ensure the .env file contains EMBY_SERVER_URL and either EMBY_API_KEY+EMBY_USER_ID or EMBY_USERNAME+EMBY_PASSWORD")
}
return cfg, nil
}
func defaultStr(s, d string) string {
if strings.TrimSpace(s) == "" {
return d
}
return s
}
func strToBool(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "true", "1", "yes", "y", "on":
return true
}
return false
}
// parseDotEnv reads KEY=VALUE lines, tolerating surrounding whitespace,
// single/double quotes, blank lines and '#' comments.
func parseDotEnv(f *os.File, out map[string]string) error {
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
eq := strings.IndexByte(line, '=')
if eq < 0 {
continue
}
key := strings.TrimSpace(line[:eq])
val := strings.TrimSpace(line[eq+1:])
if len(val) >= 2 {
if (val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'') {
val = val[1 : len(val)-1]
}
}
out[key] = val
}
return sc.Err()
}