2026-07-27 08:16:20 +12:00
|
|
|
// 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"
|
2026-08-07 10:44:17 +12:00
|
|
|
"strconv"
|
2026-07-27 08:16:20 +12:00
|
|
|
"strings"
|
|
|
|
|
"time"
|
2026-08-06 22:33:56 +12:00
|
|
|
|
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/buildinfo"
|
2026-07-27 08:16:20 +12:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Client struct {
|
|
|
|
|
baseURL string
|
|
|
|
|
publicURL string
|
|
|
|
|
clientName string
|
2026-08-06 22:33:56 +12:00
|
|
|
// gatewayVersion labels the requests the gateway makes on its own behalf — the
|
|
|
|
|
// library sync, the health probe, device cleanup — which belong to no television and
|
|
|
|
|
// so have no app version to report.
|
|
|
|
|
gatewayVersion string
|
|
|
|
|
http *http.Client
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Credentials identify one signed-in Emby user.
|
|
|
|
|
type Credentials struct {
|
2026-07-27 21:06:51 +12:00
|
|
|
UserID string
|
|
|
|
|
Token string
|
|
|
|
|
DeviceID string
|
|
|
|
|
DeviceName string
|
2026-08-06 22:33:56 +12:00
|
|
|
// ClientVersion is the app version of the television this request is being made for,
|
|
|
|
|
// as it reported in X-Memby-Version. Emby shows it beside the device, so a blank one
|
|
|
|
|
// makes every set in the house look like the same build.
|
|
|
|
|
ClientVersion string
|
2026-07-27 08:16:20 +12:00
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
type Device struct {
|
|
|
|
|
ID string `json:"Id"`
|
|
|
|
|
ReportedDeviceID string `json:"ReportedDeviceId"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type DevicesResult struct {
|
|
|
|
|
Items []Device `json:"Items"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
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"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
type User struct {
|
|
|
|
|
ID string `json:"Id"`
|
|
|
|
|
Name string `json:"Name"`
|
|
|
|
|
Policy struct {
|
|
|
|
|
IsDisabled bool `json:"IsDisabled"`
|
|
|
|
|
} `json:"Policy"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
// Summary is the minimal view of an item the gateway needs for its own logic.
|
|
|
|
|
type Summary struct {
|
2026-08-02 22:10:19 +12:00
|
|
|
ID string `json:"Id"`
|
|
|
|
|
Name string `json:"Name"`
|
|
|
|
|
Type string `json:"Type"`
|
|
|
|
|
Overview string `json:"Overview"`
|
|
|
|
|
SeriesName string `json:"SeriesName"`
|
|
|
|
|
RunTimeTicks int64 `json:"RunTimeTicks"`
|
|
|
|
|
ParentIndexNumber int `json:"ParentIndexNumber"`
|
|
|
|
|
IndexNumber int `json:"IndexNumber"`
|
|
|
|
|
UserData struct {
|
2026-07-27 08:16:20 +12:00
|
|
|
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
|
|
|
|
} `json:"UserData"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
type PlaybackInfo struct {
|
|
|
|
|
MediaSources []MediaSourceInfo `json:"MediaSources"`
|
|
|
|
|
PlaySessionID string `json:"PlaySessionId"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type MediaSourceInfo struct {
|
2026-08-02 22:10:19 +12:00
|
|
|
ID string `json:"Id"`
|
|
|
|
|
MediaStreams []MediaStream `json:"MediaStreams"`
|
|
|
|
|
SupportsDirectPlay bool `json:"SupportsDirectPlay"`
|
|
|
|
|
SupportsDirectStream bool `json:"SupportsDirectStream"`
|
|
|
|
|
SupportsTranscoding bool `json:"SupportsTranscoding"`
|
|
|
|
|
DirectStreamURL string `json:"DirectStreamUrl"`
|
|
|
|
|
TranscodingURL string `json:"TranscodingUrl"`
|
2026-07-29 15:26:27 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type MediaStream struct {
|
|
|
|
|
Index int `json:"Index"`
|
|
|
|
|
Type string `json:"Type"`
|
|
|
|
|
Codec string `json:"Codec"`
|
|
|
|
|
Title string `json:"Title"`
|
|
|
|
|
DisplayTitle string `json:"DisplayTitle"`
|
|
|
|
|
Language string `json:"Language"`
|
|
|
|
|
IsDefault bool `json:"IsDefault"`
|
|
|
|
|
IsForced bool `json:"IsForced"`
|
|
|
|
|
IsHearingImpaired bool `json:"IsHearingImpaired"`
|
|
|
|
|
IsExternal bool `json:"IsExternal"`
|
|
|
|
|
IsTextSubtitleStream bool `json:"IsTextSubtitleStream"`
|
|
|
|
|
SupportsExternalStream bool `json:"SupportsExternalStream"`
|
|
|
|
|
DeliveryURL string `json:"DeliveryUrl"`
|
|
|
|
|
DeliveryMethod string `json:"DeliveryMethod"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
// 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{
|
2026-08-06 22:33:56 +12:00
|
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
|
|
|
publicURL: strings.TrimRight(publicURL, "/"),
|
|
|
|
|
clientName: clientName,
|
|
|
|
|
gatewayVersion: buildinfo.Version(),
|
2026-07-27 08:16:20 +12:00
|
|
|
http: &http.Client{
|
|
|
|
|
Timeout: timeout,
|
|
|
|
|
Transport: &http.Transport{
|
|
|
|
|
MaxIdleConns: 100,
|
|
|
|
|
MaxIdleConnsPerHost: 20,
|
|
|
|
|
IdleConnTimeout: 90 * time.Second,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
// Authenticate signs a television in. clientVersion is the app version that set
|
|
|
|
|
// reported; it is what Emby stamps on the device record it creates here, so an empty one
|
|
|
|
|
// leaves the entry claiming the gateway's own build.
|
|
|
|
|
func (c *Client) Authenticate(ctx context.Context, username, password, deviceID, deviceName, clientVersion string) (*AuthResult, error) {
|
2026-07-27 08:16:20 +12:00
|
|
|
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,
|
2026-08-06 22:33:56 +12:00
|
|
|
Credentials{DeviceID: deviceID, DeviceName: deviceName, ClientVersion: clientVersion},
|
|
|
|
|
bytes.NewReader(body))
|
2026-07-27 08:16:20 +12:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 21:06:51 +12:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
// DeleteDevice removes the persistent Emby device record whose client-supplied ID
|
|
|
|
|
// matches reportedDeviceID. Logging out only revokes the access token; Emby deliberately
|
|
|
|
|
// keeps the device in its dashboard until the record itself is deleted.
|
|
|
|
|
func (c *Client) DeleteDevice(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
cred Credentials,
|
|
|
|
|
reportedDeviceID string,
|
|
|
|
|
) error {
|
|
|
|
|
req, err := c.newRequest(ctx, http.MethodGet, "/Devices", nil, cred, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
var devices DevicesResult
|
|
|
|
|
if err := c.do(req, &devices); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
for _, device := range devices.Items {
|
|
|
|
|
if device.ReportedDeviceID != reportedDeviceID || device.ID == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
params := url.Values{"Id": {device.ID}}
|
|
|
|
|
req, err := c.newRequest(ctx, http.MethodDelete, "/Devices", params, cred, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := c.do(req, nil); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
// Users returns the household accounts visible to an administrative/service token.
|
|
|
|
|
// It is used only by the background For You builder, never on a television request.
|
|
|
|
|
func (c *Client) Users(ctx context.Context, cred Credentials) ([]User, error) {
|
|
|
|
|
req, err := c.newRequest(ctx, http.MethodGet, "/Users", nil, cred, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
var users []User
|
|
|
|
|
if err := c.do(req, &users); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return users, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
func (c *Client) PlaybackInfo(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
cred Credentials,
|
|
|
|
|
itemID string,
|
|
|
|
|
startTicks int64,
|
|
|
|
|
subtitleStreamIndex *int,
|
|
|
|
|
currentPlaySessionID string,
|
2026-08-02 22:10:19 +12:00
|
|
|
forceTranscode bool,
|
|
|
|
|
capabilities PlaybackCapabilities,
|
2026-07-29 15:26:27 +12:00
|
|
|
) (*PlaybackInfo, error) {
|
|
|
|
|
params := url.Values{
|
|
|
|
|
"UserId": {cred.UserID},
|
|
|
|
|
"IsPlayback": {"true"},
|
|
|
|
|
}
|
|
|
|
|
body, err := json.Marshal(map[string]any{
|
|
|
|
|
"Id": itemID, "UserId": cred.UserID, "IsPlayback": true,
|
2026-08-02 22:10:19 +12:00
|
|
|
"StartTimeTicks": startTicks, "EnableDirectPlay": !forceTranscode,
|
|
|
|
|
"EnableDirectStream": !forceTranscode, "EnableTranscoding": true,
|
|
|
|
|
"AllowVideoStreamCopy": true, "AllowAudioStreamCopy": true,
|
|
|
|
|
"DeviceProfile": androidTVDeviceProfile(capabilities),
|
2026-07-29 15:26:27 +12:00
|
|
|
})
|
|
|
|
|
var requestBody map[string]any
|
|
|
|
|
if err == nil {
|
|
|
|
|
err = json.Unmarshal(body, &requestBody)
|
|
|
|
|
}
|
|
|
|
|
if subtitleStreamIndex != nil {
|
|
|
|
|
requestBody["SubtitleStreamIndex"] = *subtitleStreamIndex
|
|
|
|
|
}
|
|
|
|
|
if currentPlaySessionID != "" {
|
|
|
|
|
requestBody["CurrentPlaySessionId"] = currentPlaySessionID
|
|
|
|
|
}
|
2026-08-02 22:10:19 +12:00
|
|
|
if forceTranscode {
|
|
|
|
|
profile := requestBody["DeviceProfile"].(map[string]any)
|
|
|
|
|
profile["DirectPlayProfiles"] = []map[string]string{}
|
|
|
|
|
}
|
2026-07-29 15:26:27 +12:00
|
|
|
if err == nil {
|
|
|
|
|
body, err = json.Marshal(requestBody)
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
req, err := c.newRequest(
|
|
|
|
|
ctx,
|
|
|
|
|
http.MethodPost,
|
|
|
|
|
"/Items/"+url.PathEscape(itemID)+"/PlaybackInfo",
|
|
|
|
|
params,
|
|
|
|
|
cred,
|
|
|
|
|
bytes.NewReader(body),
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
var out PlaybackInfo
|
|
|
|
|
if err := c.do(req, &out); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return &out, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 22:10:19 +12:00
|
|
|
func directPlayVideoCodecs(supportsHEVC bool) string {
|
|
|
|
|
if supportsHEVC {
|
|
|
|
|
return "h264,hevc"
|
|
|
|
|
}
|
|
|
|
|
return "h264"
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
func (c *Client) ResumeItems(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
|
|
|
|
|
return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items/Resume", params)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 08:25:50 +12:00
|
|
|
// HideFromResume removes an item from resume and next-up feeds without marking it played.
|
|
|
|
|
func (c *Client) HideFromResume(ctx context.Context, cred Credentials, itemID string) (json.RawMessage, error) {
|
|
|
|
|
params := url.Values{"Hide": {"true"}}
|
|
|
|
|
path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID) + "/HideFromResume"
|
|
|
|
|
req, err := c.newRequest(ctx, http.MethodPost, 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
// RefreshItem asks Emby to re-scan one item's files.
|
|
|
|
|
//
|
|
|
|
|
// It exists for the subtitle download: Bazarr writes the new .srt beside the media file
|
|
|
|
|
// and Emby has no idea it is there until something makes it look. Metadata and images are
|
|
|
|
|
// deliberately left alone (ReplaceAllMetadata=false, ImageRefreshMode=None) — the point is
|
|
|
|
|
// to notice a new sidecar, not to re-fetch a title's artwork from the internet because
|
|
|
|
|
// somebody wanted Italian subtitles.
|
|
|
|
|
func (c *Client) RefreshItem(ctx context.Context, cred Credentials, itemID string) error {
|
|
|
|
|
params := url.Values{
|
|
|
|
|
"Recursive": {"false"},
|
|
|
|
|
"MetadataRefreshMode": {"Default"},
|
|
|
|
|
"ImageRefreshMode": {"None"},
|
|
|
|
|
"ReplaceAllMetadata": {"false"},
|
|
|
|
|
"ReplaceAllImages": {"false"},
|
|
|
|
|
}
|
|
|
|
|
req, err := c.newRequest(
|
|
|
|
|
ctx, http.MethodPost, "/Items/"+url.PathEscape(itemID)+"/Refresh", params, cred, nil,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return c.do(req, nil)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
// ReportPlayback forwards a progress report. phase is "started", "progress" or "stopped".
|
2026-07-29 15:26:27 +12:00
|
|
|
func (c *Client) ReportPlayback(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
cred Credentials,
|
|
|
|
|
phase, itemID, mediaSourceID, playSessionID, playMethod, eventName string,
|
|
|
|
|
positionTicks int64,
|
|
|
|
|
isPaused bool,
|
|
|
|
|
) error {
|
2026-07-27 08:16:20 +12:00
|
|
|
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,
|
2026-07-29 15:26:27 +12:00
|
|
|
"MediaSourceId": mediaSourceID,
|
|
|
|
|
"PlaySessionId": playSessionID,
|
2026-07-27 08:16:20 +12:00
|
|
|
"PositionTicks": positionTicks,
|
|
|
|
|
"IsPaused": isPaused,
|
|
|
|
|
"IsMuted": false,
|
|
|
|
|
"CanSeek": true,
|
2026-07-29 15:26:27 +12:00
|
|
|
"PlayMethod": playMethod,
|
2026-07-27 08:16:20 +12:00
|
|
|
})
|
2026-07-29 15:26:27 +12:00
|
|
|
if phase == "progress" {
|
|
|
|
|
var fields map[string]any
|
|
|
|
|
if err := json.Unmarshal(body, &fields); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
fields["EventName"] = eventName
|
|
|
|
|
body, err = json.Marshal(fields)
|
|
|
|
|
}
|
2026-07-27 08:16:20 +12:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 10:44:17 +12:00
|
|
|
// TrickplayBytes reads part of a title's preview-thumbnail file (BIF).
|
|
|
|
|
//
|
|
|
|
|
// It is always a ranged read, because the whole file is megabytes and nothing here ever
|
|
|
|
|
// wants all of it: callers take the index off the front, then one frame at a time. Emby
|
|
|
|
|
// answers ranges on this route correctly but does not say so — the response advertises
|
|
|
|
|
// "Accept-Ranges: none" and a Content-Length taken from the media file — so the request is
|
|
|
|
|
// made on the strength of the 206 rather than on what the headers promise, and a server
|
|
|
|
|
// that ignored the range would simply return the head of the file, which is the index a
|
|
|
|
|
// caller wanted anyway.
|
|
|
|
|
func (c *Client) TrickplayBytes(
|
|
|
|
|
ctx context.Context, cred Credentials, itemID string, width int, from, to int64,
|
|
|
|
|
) ([]byte, error) {
|
|
|
|
|
params := url.Values{}
|
|
|
|
|
params.Set("Width", strconv.Itoa(width))
|
|
|
|
|
path := "/Videos/" + url.PathEscape(itemID) + "/index.bif"
|
|
|
|
|
req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", from, to))
|
|
|
|
|
|
|
|
|
|
resp, err := c.http.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
if resp.StatusCode >= 400 {
|
|
|
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
|
|
|
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)}
|
|
|
|
|
}
|
|
|
|
|
// Cap the read at what was asked for. A 200 means the range was ignored and the whole
|
|
|
|
|
// file is on its way, which must not become a multi-megabyte read on a seek.
|
|
|
|
|
return io.ReadAll(io.LimitReader(resp.Body, to-from+1))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 08:25:50 +12:00
|
|
|
// MaxSubtitleBytes caps a subtitle read. A feature-length SubRip file is tens of
|
|
|
|
|
// kilobytes; a megabyte is far past anything genuine and is what an Emby that answered
|
|
|
|
|
// with the media file instead would send.
|
|
|
|
|
const MaxSubtitleBytes = 4 << 20
|
|
|
|
|
|
|
|
|
|
// SubtitleBytes reads a text subtitle track's content.
|
|
|
|
|
//
|
|
|
|
|
// This is the one place the gateway pulls a subtitle *out* of Emby rather than pointing
|
|
|
|
|
// the television at it, and it exists so a track can be used as a timing reference. SubRip
|
|
|
|
|
// is asked for because that is what the sync reads and writes; Emby converts from whatever
|
|
|
|
|
// the container holds.
|
|
|
|
|
func (c *Client) SubtitleBytes(
|
|
|
|
|
ctx context.Context, cred Credentials, itemID, mediaSourceID string, index int,
|
|
|
|
|
) ([]byte, error) {
|
|
|
|
|
if mediaSourceID == "" {
|
|
|
|
|
mediaSourceID = itemID
|
|
|
|
|
}
|
|
|
|
|
path := fmt.Sprintf(
|
|
|
|
|
"/Videos/%s/%s/Subtitles/%d/Stream.srt",
|
|
|
|
|
url.PathEscape(itemID), url.PathEscape(mediaSourceID), index,
|
|
|
|
|
)
|
|
|
|
|
req, err := c.newRequest(ctx, http.MethodGet, path, nil, cred, nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
resp, err := c.http.Do(req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
if resp.StatusCode >= 400 {
|
|
|
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
|
|
|
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)}
|
|
|
|
|
}
|
|
|
|
|
return io.ReadAll(io.LimitReader(resp.Body, MaxSubtitleBytes))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
// 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())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 15:26:27 +12:00
|
|
|
// DeliveryURL converts a PlaybackInfo URL into a TV-reachable, authenticated URL.
|
|
|
|
|
func (c *Client) DeliveryURL(cred Credentials, delivery string) string {
|
|
|
|
|
delivery = strings.TrimSpace(delivery)
|
|
|
|
|
if delivery == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
var resolved string
|
|
|
|
|
if parsed, err := url.Parse(delivery); err == nil && parsed.IsAbs() {
|
|
|
|
|
resolved = parsed.String()
|
|
|
|
|
} else {
|
|
|
|
|
resolved = c.publicURL + "/" + strings.TrimLeft(delivery, "/")
|
|
|
|
|
}
|
|
|
|
|
parsed, err := url.Parse(resolved)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return resolved
|
|
|
|
|
}
|
|
|
|
|
query := parsed.Query()
|
|
|
|
|
if query.Get("api_key") == "" {
|
|
|
|
|
query.Set("api_key", cred.Token)
|
|
|
|
|
parsed.RawQuery = query.Encode()
|
|
|
|
|
}
|
|
|
|
|
return parsed.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// SubtitleURL uses Emby's stable subtitle download route rather than the optional
|
|
|
|
|
// MediaStream.DeliveryUrl. VTT normalizes every text subtitle codec before it reaches
|
|
|
|
|
// the TV.
|
|
|
|
|
func (c *Client) SubtitleURL(cred Credentials, itemID, mediaSourceID string, index int, extensions ...string) string {
|
|
|
|
|
if mediaSourceID == "" {
|
|
|
|
|
mediaSourceID = itemID
|
|
|
|
|
}
|
|
|
|
|
extension := "vtt"
|
|
|
|
|
if len(extensions) > 0 && strings.TrimSpace(extensions[0]) != "" {
|
|
|
|
|
extension = extensions[0]
|
|
|
|
|
}
|
|
|
|
|
path := fmt.Sprintf(
|
|
|
|
|
"/Videos/%s/%s/Subtitles/%d/Stream.%s",
|
|
|
|
|
url.PathEscape(itemID),
|
|
|
|
|
url.PathEscape(mediaSourceID),
|
|
|
|
|
index, url.PathEscape(extension),
|
|
|
|
|
)
|
|
|
|
|
return c.DeliveryURL(cred, path)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
// 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"
|
|
|
|
|
}
|
2026-07-27 21:06:51 +12:00
|
|
|
deviceName := strings.TrimSpace(cred.DeviceName)
|
|
|
|
|
if deviceName == "" {
|
|
|
|
|
deviceName = "Memby TV"
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
// The version Emby records is the television's app version, not a constant: every
|
|
|
|
|
// device in the dashboard read as one build before this, so there was no way to tell
|
|
|
|
|
// which set was behind. A request the gateway makes for itself reports its own build.
|
|
|
|
|
version := strings.TrimSpace(cred.ClientVersion)
|
|
|
|
|
if version == "" {
|
|
|
|
|
version = c.gatewayVersion
|
|
|
|
|
}
|
2026-07-27 08:16:20 +12:00
|
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
|
req.Header.Set("X-Emby-Authorization", fmt.Sprintf(
|
2026-08-06 22:33:56 +12:00
|
|
|
`MediaBrowser Client="%s", Device="%s", DeviceId="%s", Version="%s"`,
|
2026-07-27 21:06:51 +12:00
|
|
|
c.clientName, strings.ReplaceAll(deviceName, `"`, ""), deviceID,
|
2026-08-06 22:33:56 +12:00
|
|
|
strings.ReplaceAll(version, `"`, ""),
|
2026-07-27 08:16:20 +12:00
|
|
|
))
|
|
|
|
|
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
|
|
|
|
|
}
|