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.
201 lines
6.7 KiB
Go
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()
|
|
}
|