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
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.
160 lines
4.5 KiB
Go
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
|
|
}
|