Files
gronod d40646211f
Build and publish / Test and build (darwin) (push) Failing after 1m42s
Build and publish / Test and build (linux) (push) Failing after 1m53s
Build and publish / Test and build (windows) (push) Failing after 2m50s
Build and publish / Build and publish Docker image (push) Skipped
Initial Go implementation of Emby.MCP
Go rewrite of the Python Emby.MCP server with tool parity (20 tools),
plus a streamable-HTTP transport with per-request Emby authentication
(Basic / Bearer token passthrough), a multi-arch Docker build, and a
Gitea CI workflow.
2026-09-20 22:52:46 +01:00

160 lines
4.5 KiB
Go

// Package emby is a minimal REST client for the Emby media server API,
// replacing the Python embyclient SDK used by the original implementation.
package emby
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Client talks to one Emby server.
type Client struct {
baseURL string
apiKey string // X-Emby-Token (API key or acquired access token)
deviceID string
clientName string
clientVer string
deviceName string
authHeader string // X-Emby-Authorization value
httpClient *http.Client
usesPassword bool // true when authenticated via AuthenticateByName
}
// Options carries client identification shown in Emby server logs/devices page.
type Options struct {
ClientName string // e.g. "Emby.MCP for AI"
ClientVersion string // e.g. "1.0.2"
DeviceName string // e.g. "myhost (Darwin)"
DeviceID string // stable unique id; generated if empty
VerifySSL bool
}
// NewClient creates an unauthenticated client. Call Authenticate or
// SetAPIKey before making other calls.
func NewClient(baseURL string, opts Options) *Client {
c := &Client{
baseURL: strings.TrimRight(baseURL, "/"),
clientName: opts.ClientName,
clientVer: opts.ClientVersion,
deviceName: opts.DeviceName,
deviceID: opts.DeviceID,
httpClient: &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: !opts.VerifySSL}, //nolint:gosec // user-configured
},
},
}
c.authHeader = c.buildAuthHeader("")
return c
}
// buildAuthHeader builds the X-Emby-Authorization header value.
func (c *Client) buildAuthHeader(userID string) string {
return fmt.Sprintf(`Emby UserId="%s", Client="%s", Device="%s", DeviceId="%s", Version="%s"`,
userID, c.clientName, c.deviceName, c.deviceID, c.clientVer)
}
// SetAPIKey configures the client to use a pre-existing API key.
func (c *Client) SetAPIKey(key string) {
c.apiKey = key
}
// SetToken installs a pre-existing Emby access token or API key, e.g. a token
// passed through from an HTTP client. It does not mark the client as
// password-authenticated, so Logout is not attempted for it.
func (c *Client) SetToken(token, userID string) {
c.apiKey = token
c.authHeader = c.buildAuthHeader(userID)
}
// SetToken installs an access token acquired via Authenticate.
func (c *Client) setToken(token, userID string) {
c.apiKey = token
c.authHeader = c.buildAuthHeader(userID)
c.usesPassword = true
}
// UsesPasswordAuth reports whether the client authenticated with
// username/password (and therefore should log out on shutdown).
func (c *Client) UsesPasswordAuth() bool { return c.usesPassword }
// do performs an HTTP request and JSON-decodes the response into out
// (if non-nil). Non-2xx/3xx responses become errors containing the body.
func (c *Client) do(ctx context.Context, method, path string, q url.Values, body, out any) error {
u, err := url.Parse(c.baseURL + path)
if err != nil {
return err
}
if len(q) > 0 {
u.RawQuery = q.Encode()
}
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, u.String(), rdr)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("X-Emby-Authorization", c.authHeader)
if c.apiKey != "" {
req.Header.Set("X-Emby-Token", c.apiKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(b)))
}
if out == nil {
io.Copy(io.Discard, resp.Body)
return nil
}
return json.NewDecoder(resp.Body).Decode(out)
}
// Get performs a GET request.
func (c *Client) Get(ctx context.Context, path string, q url.Values, out any) error {
return c.do(ctx, http.MethodGet, path, q, nil, out)
}
// Post performs a POST request with an optional JSON body.
func (c *Client) Post(ctx context.Context, path string, q url.Values, body, out any) error {
return c.do(ctx, http.MethodPost, path, q, body, out)
}
// qp is a tiny helper to build url.Values.
func qp(pairs ...string) url.Values {
v := url.Values{}
for i := 0; i+1 < len(pairs); i += 2 {
if pairs[i+1] != "" {
v.Set(pairs[i], pairs[i+1])
}
}
return v
}