Files
gronod 2d5763d6c6 feat: slim tool responses, drop lyrics search, rename module (1.0.11)
Item-shaped results now omit empty fields and drop low-value metadata
(overview, lyrics, file_path, creation_date, premiere_date, bitrate);
absent numeric fields drop the key instead of emitting "". Player
sessions report hh:mm:ss times only; playlists drop overview/date_created.
The lyrics_or_description search parameter is removed, along with the
unused DTO fields, and upstream Fields requests are slimmed to match.
Module path renamed to git.i3omb.com/gronod/emby-mcp.
2026-09-21 23:54:07 +01:00

213 lines
5.9 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"
"git.i3omb.com/gronod/emby-mcp/internal/applog"
"strings"
"sync"
"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
username string
password string
mu sync.Mutex
}
// 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)
}
var reqDump []byte
if body != nil {
reqDump, _ = json.Marshal(body)
}
applog.Emby("%s %s req=%s", method, path, applog.Body(reqDump))
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
resp.Body = io.NopCloser(bytes.NewReader(respBytes))
applog.Emby("%s %s status=%d bytes=%d resp=%s", method, path, resp.StatusCode, len(respBytes), applog.Body(respBytes))
already, _ := ctx.Value(refreshCtxKey{}).(bool)
if resp.StatusCode == http.StatusUnauthorized && !already && path != "/Users/AuthenticateByName" && c.canRefresh() {
_, _ = io.Copy(io.Discard, resp.Body)
if err := c.refreshAuth(ctx); err == nil {
return c.do(context.WithValue(ctx, refreshCtxKey{}, true), method, path, q, body, out)
}
}
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)
}
type refreshCtxKey struct{}
func (c *Client) canRefresh() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.username != "" && c.password != ""
}
// RememberPassword stores credentials used to mint a new access token after 401.
func (c *Client) RememberPassword(username, password string) {
c.mu.Lock()
defer c.mu.Unlock()
c.username = username
c.password = password
}
func (c *Client) refreshAuth(ctx context.Context) error {
c.mu.Lock()
user, pass := c.username, c.password
c.mu.Unlock()
if user == "" || pass == "" {
return fmt.Errorf("no password credentials to refresh Emby token")
}
_, err := c.Authenticate(ctx, user, pass)
return err
}
// 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
}