Files
gronod 7bcf5322e1 feat: emby-mcp 1.0.16 player owner filter and HA links
Expose session/last-used user and device IP on player rows. Filter
retrieve_player_list by users, merge GET /Devices when include_offline
is set, and resolve spoken names to HA media_player entities via
player_links (device_id or IP). PlayNow still requires a live session.
2026-09-25 19:58:58 +00:00

201 lines
6.7 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 "0.0.0.0:8085"
SessionTimeout time.Duration // MCP_SESSION_TIMEOUT, default 30m; <=0 disables
RestrictToLocalhost bool // MCP_RESTRICT_LOCALHOST
LogLevel string // LOG_LEVEL: DEBUG, INFO, WARN
DebugREST bool // DEBUG_REST
DebugMCP bool // DEBUG_MCP
DebugEmby bool // DEBUG_EMBY
PlayerUsers []string // PLAYER_USERS include-list (names or ids)
PlayerLinks []PlayerLink // PLAYER_LINKS JSON mappings to HA entities
}
// PlayerLink maps spoken names / Emby device identity onto a Home Assistant media_player.
type PlayerLink struct {
Names []string `json:"names"`
EmbyDeviceID string `json:"emby_device_id"`
EmbyDeviceName string `json:"emby_device_name"`
DeviceIP string `json:"device_ip"`
HAEntity string `json:"ha_entity"`
HASource string `json:"ha_source"`
Users []string `json:"users"`
}
// 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",
"MCP_RESTRICT_LOCALHOST",
"LOG_LEVEL", "DEBUG_REST", "DEBUG_MCP", "DEBUG_EMBY",
"PLAYER_USERS", "PLAYER_LINKS",
} {
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"], "0.0.0.0:8085")
cfg.RestrictToLocalhost = strToBool(defaultStr(vals["MCP_RESTRICT_LOCALHOST"], "false"))
cfg.LogLevel = strings.ToUpper(defaultStr(vals["LOG_LEVEL"], "INFO"))
switch cfg.LogLevel {
case "DEBUG", "INFO", "WARN", "WARNING":
if cfg.LogLevel == "WARNING" {
cfg.LogLevel = "WARN"
}
default:
cfg.LogLevel = "INFO"
}
cfg.DebugREST = strToBool(defaultStr(vals["DEBUG_REST"], "false"))
cfg.DebugMCP = strToBool(defaultStr(vals["DEBUG_MCP"], "false"))
cfg.DebugEmby = strToBool(defaultStr(vals["DEBUG_EMBY"], "false"))
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
}
cfg.PlayerUsers = SplitCSV(vals["PLAYER_USERS"])
if s := strings.TrimSpace(vals["PLAYER_LINKS"]); s != "" {
links, err := ParsePlayerLinks(s)
if err != nil {
return nil, fmt.Errorf("invalid PLAYER_LINKS: %w", err)
}
cfg.PlayerLinks = links
}
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 forceLoopback(addr string) string {
addr = strings.TrimSpace(addr)
if addr == "" {
return "127.0.0.1:8085"
}
// ":8085" or "0.0.0.0:8085" or "[::]:8085" → 127.0.0.1:<port>
if strings.HasPrefix(addr, ":") {
return "127.0.0.1" + addr
}
if i := strings.LastIndex(addr, ":"); i >= 0 {
return "127.0.0.1" + addr[i:]
}
return "127.0.0.1:8085"
}
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()
}