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.
213 lines
5.9 KiB
Go
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
|
|
}
|