// Package emby is a small client for the Emby REST API. // // Item payloads are deliberately carried as json.RawMessage and forwarded to the TV // untouched: the Android client already models Emby's item shape, so passing it through // verbatim means there is no second schema to keep in sync. Only the handful of fields // the gateway itself reasons about (id, type, resume position) are ever unmarshalled. package emby import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) type Client struct { baseURL string publicURL string clientName string http *http.Client } // Credentials identify one signed-in Emby user. type Credentials struct { UserID string Token string DeviceID string DeviceName string } type ItemsResult struct { Items []json.RawMessage `json:"Items"` TotalRecordCount int `json:"TotalRecordCount"` } type AuthResult struct { User struct { ID string `json:"Id"` Name string `json:"Name"` } `json:"User"` AccessToken string `json:"AccessToken"` ServerID string `json:"ServerId"` } // Summary is the minimal view of an item the gateway needs for its own logic. type Summary struct { ID string `json:"Id"` Name string `json:"Name"` Type string `json:"Type"` UserData struct { PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"` } `json:"UserData"` } // APIError carries an upstream Emby status code so handlers can mirror it. type APIError struct { StatusCode int Body string } func (e *APIError) Error() string { return fmt.Sprintf("emby: status %d: %s", e.StatusCode, e.Body) } func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), publicURL: strings.TrimRight(publicURL, "/"), clientName: clientName, http: &http.Client{ Timeout: timeout, Transport: &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 20, IdleConnTimeout: 90 * time.Second, }, }, } } func (c *Client) Authenticate(ctx context.Context, username, password, deviceID, deviceName string) (*AuthResult, error) { body, err := json.Marshal(map[string]string{"Username": username, "Pw": password}) if err != nil { return nil, err } req, err := c.newRequest(ctx, http.MethodPost, "/Users/AuthenticateByName", nil, Credentials{DeviceID: deviceID, DeviceName: deviceName}, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") var out AuthResult if err := c.do(req, &out); err != nil { return nil, err } if out.AccessToken == "" || out.User.ID == "" { return nil, fmt.Errorf("emby: authentication returned no access token") } return &out, nil } // Logout retires a token created during authentication. The gateway uses this when a // device is refused by policy so Emby is not left holding an orphaned session. func (c *Client) Logout(ctx context.Context, cred Credentials) error { req, err := c.newRequest(ctx, http.MethodPost, "/Sessions/Logout", nil, cred, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) Items(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) { return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items", params) } func (c *Client) NextUp(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) { params.Set("UserId", cred.UserID) return c.items(ctx, cred, "/Shows/NextUp", params) } // Similar asks Emby which items resemble one the user already watched. func (c *Client) Similar(ctx context.Context, cred Credentials, itemID string, params url.Values) (*ItemsResult, error) { params.Set("UserId", cred.UserID) return c.items(ctx, cred, "/Items/"+url.PathEscape(itemID)+"/Similar", params) } func (c *Client) Episodes(ctx context.Context, cred Credentials, seriesID string, params url.Values) (*ItemsResult, error) { params.Set("UserId", cred.UserID) return c.items(ctx, cred, "/Shows/"+url.PathEscape(seriesID)+"/Episodes", params) } // LocalTrailers returns the trailers Emby holds locally for an item. func (c *Client) LocalTrailers(ctx context.Context, cred Credentials, itemID string) (*ItemsResult, error) { path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID) + "/LocalTrailers" req, err := c.newRequest(ctx, http.MethodGet, path, nil, cred, nil) if err != nil { return nil, err } // This endpoint answers with a bare array rather than an Items envelope. var items []json.RawMessage if err := c.do(req, &items); err != nil { return nil, err } return &ItemsResult{Items: items, TotalRecordCount: len(items)}, nil } func (c *Client) Item(ctx context.Context, cred Credentials, itemID, fields string) (json.RawMessage, error) { params := url.Values{} if fields != "" { params.Set("Fields", fields) } path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID) req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil) if err != nil { return nil, err } var raw json.RawMessage if err := c.do(req, &raw); err != nil { return nil, err } return raw, nil } // SetFavorite/SetPlayed return Emby's resulting UserData verbatim. func (c *Client) SetFavorite(ctx context.Context, cred Credentials, itemID string, favorite bool) (json.RawMessage, error) { method := http.MethodDelete if favorite { method = http.MethodPost } path := "/Users/" + url.PathEscape(cred.UserID) + "/FavoriteItems/" + url.PathEscape(itemID) return c.userDataCall(ctx, method, path, cred) } func (c *Client) SetPlayed(ctx context.Context, cred Credentials, itemID string, played bool) (json.RawMessage, error) { method := http.MethodDelete if played { method = http.MethodPost } path := "/Users/" + url.PathEscape(cred.UserID) + "/PlayedItems/" + url.PathEscape(itemID) return c.userDataCall(ctx, method, path, cred) } // ReportPlayback forwards a progress report. phase is "started", "progress" or "stopped". func (c *Client) ReportPlayback(ctx context.Context, cred Credentials, phase string, itemID string, positionTicks int64, isPaused bool) error { var path string switch phase { case "started": path = "/Sessions/Playing" case "progress": path = "/Sessions/Playing/Progress" case "stopped": path = "/Sessions/Playing/Stopped" default: return fmt.Errorf("emby: unknown playback phase %q", phase) } body, err := json.Marshal(map[string]any{ "ItemId": itemID, "PositionTicks": positionTicks, "IsPaused": isPaused, "IsMuted": false, "CanSeek": true, "PlayMethod": "DirectPlay", }) if err != nil { return err } req, err := c.newRequest(ctx, http.MethodPost, path, nil, cred, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") return c.do(req, nil) } // ImageResponse streams an image straight from Emby so the caller can copy it to the TV. // The caller owns closing the body. func (c *Client) ImageResponse(ctx context.Context, cred Credentials, itemID, imageType string, params url.Values) (*http.Response, error) { path := "/Items/" + url.PathEscape(itemID) + "/Images/" + url.PathEscape(imageType) req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil) if err != nil { return nil, err } resp, err := c.http.Do(req) if err != nil { return nil, err } if resp.StatusCode >= 400 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) resp.Body.Close() return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)} } return resp, nil } // StreamURL is the direct-play URL handed to the TV. It points at the *public* Emby // address: video never flows through the gateway, only metadata does. func (c *Client) StreamURL(cred Credentials, itemID string) string { params := url.Values{} params.Set("static", "true") params.Set("api_key", cred.Token) params.Set("DeviceId", cred.DeviceID) return fmt.Sprintf("%s/Videos/%s/stream?%s", c.publicURL, url.PathEscape(itemID), params.Encode()) } // Ping checks that Emby is reachable, for readiness probes. func (c *Client) Ping(ctx context.Context) error { req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil, Credentials{}, nil) if err != nil { return err } return c.do(req, nil) } func (c *Client) items(ctx context.Context, cred Credentials, path string, params url.Values) (*ItemsResult, error) { req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil) if err != nil { return nil, err } var out ItemsResult if err := c.do(req, &out); err != nil { return nil, err } return &out, nil } func (c *Client) userDataCall(ctx context.Context, method, path string, cred Credentials) (json.RawMessage, error) { req, err := c.newRequest(ctx, method, path, nil, cred, nil) if err != nil { return nil, err } var raw json.RawMessage if err := c.do(req, &raw); err != nil { return nil, err } return raw, nil } func (c *Client) newRequest(ctx context.Context, method, path string, params url.Values, cred Credentials, body io.Reader) (*http.Request, error) { full := c.baseURL + path if len(params) > 0 { full += "?" + params.Encode() } req, err := http.NewRequestWithContext(ctx, method, full, body) if err != nil { return nil, err } deviceID := cred.DeviceID if deviceID == "" { deviceID = "memby-gateway" } deviceName := strings.TrimSpace(cred.DeviceName) if deviceName == "" { deviceName = "Memby TV" } req.Header.Set("Accept", "application/json") req.Header.Set("X-Emby-Authorization", fmt.Sprintf( `MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="1.0"`, c.clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID, )) if cred.Token != "" { req.Header.Set("X-Emby-Token", cred.Token) } return req, nil } // do executes a request and decodes into out (which may be nil to discard the body). func (c *Client) do(req *http.Request, out any) error { resp, err := c.http.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { // Emby error bodies can echo request details; cap what we keep and never log it // alongside a token. body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return &APIError{StatusCode: resp.StatusCode, Body: string(body)} } if out == nil { _, _ = io.Copy(io.Discard, resp.Body) return nil } return json.NewDecoder(resp.Body).Decode(out) } // Summarise unmarshals the fields the gateway reasons about from a raw item. func Summarise(raw json.RawMessage) (Summary, error) { var s Summary err := json.Unmarshal(raw, &s) return s, err }